UNPKG

@adobe/git-server

Version:

serve a git repository over http(s)

238 lines (215 loc) 7.54 kB
/* * Copyright 2018 Adobe. All rights reserved. * This file is licensed to you under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. You may obtain a copy * of the License at http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software distributed under * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS * OF ANY KIND, either express or implied. See the License for the specific language * governing permissions and limitations under the License. */ 'use strict'; const { join: joinPaths, relative: relativePaths } = require('path'); const Archiver = require('archiver'); const async = require('async'); const fse = require('fs-extra'); const klaw = require('klaw'); const Ignore = require('ignore'); const { isCheckedOut, createBlobReadStream, resolveTree, collectTreeEntries, } = require('./git'); const { resolveRepositoryPath } = require('./utils'); const CACHE_DIR = './tmp'; /** * Serializes the specified git tree as an archive (zip/tgz). * * @param {string} repPath git repository path * @param {object} tree git tree to process * @param {object} archiver Archiver instance * @returns {Promise<stream.Readable>} readable stream of archive */ async function archiveGitTree(repPath, tree, archive) { // recursively collect all entries (blobs and trees) const allEntries = await collectTreeEntries(repPath, tree, [], '', true); const process = async ({ type, oid, path }) => { if (type === 'tree' || type === 'commit') { // directory or submodule archive.append(null, { name: `${path}/` }); } else { // blob const stream = await createBlobReadStream(repPath, oid); archive.append(stream, { name: path }); } }; return new Promise((resolve, reject) => { async.eachSeries( allEntries, async.asyncify(process), (err) => { if (err) { reject(err); } else { resolve(archive); } }, ); }); } /** * Recursively collects all directory entries (files and directories). * * @param {string} dirPath directory path * @param {Array<{{path: string, stats: fs.Stats}}>} allEntries array where entries will be added * @returns {Promise<Array<{{path: string, stats: fs.Stats}}>>} collected entries */ async function collectFSEntries(dirPath, allEntries) { // apply .gitignore rules const ignore = Ignore(); const ignoreFilePath = joinPaths(dirPath, '.gitignore'); if (await fse.pathExists(ignoreFilePath)) { const data = await fse.readFile(ignoreFilePath); ignore.add(data.toString()); } ignore.add('.git'); const filterIgnored = (item) => !ignore.ignores(relativePaths(dirPath, item)); return new Promise((resolve, reject) => { klaw(dirPath, { filter: filterIgnored }) .on('readable', function onAvail() { let item = this.read(); while (item) { allEntries.push(item); item = this.read(); } }) .on('error', (err) => reject(err)) .on('end', () => resolve(allEntries)); }); } /** * Serializes the specified git working directory as an archive (zip/tgz). * * @param {string} dirPath working directory * @param {object} archiver Archiver instance * @returns {Promise<stream.Readable>} readable stream of archive */ async function archiveWorkingDir(dirPath, archive) { // recursively collect all entries (files and directories) const allEntries = await collectFSEntries(dirPath, []); const process = (entry, cb) => { const p = relativePaths(dirPath, entry.path); if (p.length) { if (entry.stats.isDirectory()) { archive.append(null, { name: `${p}/` }); } else { archive.append(fse.createReadStream(entry.path), { name: p }); } } cb(); }; return new Promise((resolve, reject) => { async.eachSeries( allEntries, process, (err) => { if (err) { reject(err); } else { resolve(archive); } }, ); }); } /** * Export the archive handler (express middleware) through a parameterizable function * * @param {object} options configuration hash * @param {string} archiveFormat 'zip' or 'tar.gz' * @returns {function(*, *, *)} handler function */ function createMiddleware(options, archiveFormat) { /** * Express middleware handling GitHub 'codeload' archive requests * * @param {Request} req Request object * @param {Response} res Response object * @param {callback} next next middleware in chain * * @see https://developer.github.com/v3/repos/contents/#get-archive-link */ return async (req, res, next) => { // GET /:owner/:repo/:archive_format/:ref const { owner } = req.params; const repoName = req.params.repo; const refName = req.params.ref; const repPath = resolveRepositoryPath(options, owner, repoName); // project-helix/#187: serve modified content only if the requested ref is currently checked out const serveUncommitted = await isCheckedOut(repPath, refName); let commitSha; let archiveFileName; let archiveFilePath; resolveTree(repPath, refName) .then(async ({ object: tree }) => { archiveFileName = `${owner}-${repoName}-${serveUncommitted ? 'SNAPSHOT' : commitSha}${archiveFormat === 'zip' ? '.zip' : '.tgz'}`; archiveFilePath = joinPaths(CACHE_DIR, archiveFileName); await fse.ensureDir(CACHE_DIR); // check cache if (!serveUncommitted && await fse.pathExists(archiveFilePath)) { // no need to build archive, use cached archive file return fse.createReadStream(archiveFilePath); // lgtm [js/path-injection] } // build archive let archive; if (archiveFormat === 'zip') { // zip archive = new Archiver('zip', { zlib: { level: 9 }, // compression level }); } else { // tar.gz archive = new Archiver('tar', { gzip: true, gzipOptions: { level: 9, // compression level }, }); } if (serveUncommitted) { // don't cache archive = await archiveWorkingDir(repPath, archive); } else { archive = await archiveGitTree(repPath, tree, archive); } return new Promise((resolve, reject) => { if (serveUncommitted) { // don't cache archive.finalize(); resolve(archive); } else { // cache archive file archive.pipe(fse.createWriteStream(archiveFilePath)) // lgtm [js/path-injection] .on('finish', () => resolve(fse.createReadStream(archiveFilePath))) // lgtm [js/path-injection] .on('error', (err) => reject(err)); archive.finalize(); } }); }) .then((archiveStream) => { const mimeType = archiveFormat === 'zip' ? 'application/zip' : 'application/x-gzip'; res.writeHead(200, { 'Content-Type': mimeType, 'Content-Disposition': `attachment; filename=${archiveFileName}`, }); archiveStream.pipe(res); }) .catch((err) => { options.logger.debug(`[archiveHandler] code: ${err.code} message: ${err.message} stack: ${err.stack}`); next(err); }); }; } module.exports = createMiddleware;