UNPKG

8.75 kBJavaScriptView Raw
1/*
2 * Copyright 2018 Adobe. All rights reserved.
3 * This file is licensed to you under the Apache License, Version 2.0 (the "License");
4 * you may not use this file except in compliance with the License. You may obtain a copy
5 * of the License at http://www.apache.org/licenses/LICENSE-2.0
6 *
7 * Unless required by applicable law or agreed to in writing, software distributed under
8 * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS
9 * OF ANY KIND, either express or implied. See the License for the specific language
10 * governing permissions and limitations under the License.
11 */
12
13'use strict';
14
15const { join: joinPaths, relative: relativePaths } = require('path');
16
17const Archiver = require('archiver');
18const async = require('async');
19const fse = require('fs-extra');
20const klaw = require('klaw');
21const Ignore = require('ignore');
22const { debug } = require('@adobe/helix-log');
23
24const {
25 isCheckedOut,
26 createBlobReadStream,
27 resolveCommit,
28 getObject,
29} = require('./git');
30const { resolveRepositoryPath } = require('./utils');
31
32const CACHE_DIR = './tmp';
33
34/**
35 * Recursively collects all tree entries (blobs and trees).
36 *
37 * @param {string} repPath git repository path
38 * @param {object} tree git tree to process
39 * @param {Array<object>} result array where tree entries will be collected
40 * @param {string} treePath path of specified tree (will be prepended to child entries)
41 * @returns {Promise<Array<object>>} collected entries
42 */
43async function collectTreeEntries(repPath, tree, result, treePath) {
44 const entries = await Promise.all(tree.entries.map(async ({
45 oid, type, mode, path,
46 }) => ({
47 oid, type, mode, path: joinPaths(treePath, path),
48 })));
49 result.push(...entries);
50 // recurse into subtrees
51 const treeEntries = entries.filter((entry) => entry.type === 'tree');
52 for (let i = 0; i < treeEntries.length; i += 1) {
53 const { oid, path } = treeEntries[i];
54 /* eslint-disable no-await-in-loop */
55 const { object: subTree } = await getObject(repPath, oid);
56 await collectTreeEntries(repPath, subTree, result, path);
57 }
58 return result;
59}
60
61/**
62 * Serializes the specified git tree as an archive (zip/tgz).
63 *
64 * @param {string} repPath git repository path
65 * @param {object} tree git tree to process
66 * @param {object} archiver Archiver instance
67 * @returns {Promise<stream.Readable>} readable stream of archive
68 */
69async function archiveGitTree(repPath, tree, archive) {
70 // recursively collect all entries (blobs and trees)
71 const allEntries = await collectTreeEntries(repPath, tree, [], '');
72
73 const process = async ({ type, oid, path }) => {
74 if (type === 'tree' || type === 'commit') {
75 // directory or submodule
76 archive.append(null, { name: `${path}/` });
77 } else {
78 // blob
79 const stream = await createBlobReadStream(repPath, oid);
80 archive.append(stream, { name: path });
81 }
82 };
83
84 return new Promise((resolve, reject) => {
85 async.eachSeries(
86 allEntries,
87 async.asyncify(process),
88 (err) => {
89 if (err) {
90 reject(err);
91 } else {
92 resolve(archive);
93 }
94 },
95 );
96 });
97}
98
99/**
100 * Recursively collects all directory entries (files and directories).
101 *
102 * @param {string} dirPath directory path
103 * @param {Array<{{path: string, stats: fs.Stats}}>} allEntries array where entries will be added
104 * @returns {Promise<Array<{{path: string, stats: fs.Stats}}>>} collected entries
105 */
106async function collectFSEntries(dirPath, allEntries) {
107 // apply .gitignore rules
108 const ignore = Ignore();
109 const ignoreFilePath = joinPaths(dirPath, '.gitignore');
110 if (await fse.pathExists(ignoreFilePath)) {
111 const data = await fse.readFile(ignoreFilePath);
112 ignore.add(data.toString());
113 }
114 ignore.add('.git');
115
116 const filterIgnored = (item) => !ignore.ignores(relativePaths(dirPath, item));
117
118 return new Promise((resolve, reject) => {
119 klaw(dirPath, { filter: filterIgnored })
120 .on('readable', function onAvail() {
121 let item = this.read();
122 while (item) {
123 allEntries.push(item);
124 item = this.read();
125 }
126 })
127 .on('error', (err) => reject(err))
128 .on('end', () => resolve(allEntries));
129 });
130}
131
132/**
133 * Serializes the specified git working directory as an archive (zip/tgz).
134 *
135 * @param {string} dirPath working directory
136 * @param {object} archiver Archiver instance
137 * @returns {Promise<stream.Readable>} readable stream of archive
138 */
139async function archiveWorkingDir(dirPath, archive) {
140 // recursively collect all entries (files and directories)
141 const allEntries = await collectFSEntries(dirPath, []);
142
143 const process = (entry, cb) => {
144 const p = relativePaths(dirPath, entry.path);
145 if (p.length) {
146 if (entry.stats.isDirectory()) {
147 archive.append(null, { name: `${p}/` });
148 } else {
149 archive.append(fse.createReadStream(entry.path), { name: p });
150 }
151 }
152 cb();
153 };
154
155 return new Promise((resolve, reject) => {
156 async.eachSeries(
157 allEntries,
158 process,
159 (err) => {
160 if (err) {
161 reject(err);
162 } else {
163 resolve(archive);
164 }
165 },
166 );
167 });
168}
169
170/**
171 * Export the archive handler (express middleware) through a parameterizable function
172 *
173 * @param {object} options configuration hash
174 * @param {string} archiveFormat 'zip' or 'tar.gz'
175 * @returns {function(*, *, *)} handler function
176 */
177function createMiddleware(options, archiveFormat) {
178 /**
179 * Express middleware handling GitHub 'codeload' archive requests
180 *
181 * @param {Request} req Request object
182 * @param {Response} res Response object
183 * @param {callback} next next middleware in chain
184 *
185 * @see https://developer.github.com/v3/repos/contents/#get-archive-link
186 */
187 return async (req, res, next) => {
188 // GET /:owner/:repo/:archive_format/:ref
189 const { owner } = req.params;
190 const repoName = req.params.repo;
191 const refName = req.params.ref;
192
193 const repPath = resolveRepositoryPath(options, owner, repoName);
194
195 // project-helix/#187: serve modified content only if the requested ref is currently checked out
196 const serveUncommitted = await isCheckedOut(repPath, refName);
197
198 let commitSha;
199 let archiveFileName;
200 let archiveFilePath;
201
202 resolveCommit(repPath, refName)
203 .then((oid) => {
204 commitSha = oid;
205 return getObject(repPath, commitSha);
206 })
207 .then(({ object: commit }) => getObject(repPath, commit.tree))
208 .then(async ({ object: tree }) => {
209 archiveFileName = `${owner}-${repoName}-${serveUncommitted ? 'SNAPSHOT' : commitSha}${archiveFormat === 'zip' ? '.zip' : '.tgz'}`;
210 archiveFilePath = joinPaths(CACHE_DIR, archiveFileName);
211 await fse.ensureDir(CACHE_DIR);
212
213 // check cache
214 if (!serveUncommitted && await fse.pathExists(archiveFilePath)) {
215 // no need to build archive, use cached archive file
216 return fse.createReadStream(archiveFilePath); // lgtm [js/path-injection]
217 }
218
219 // build archive
220 let archive;
221 if (archiveFormat === 'zip') {
222 // zip
223 archive = new Archiver('zip', {
224 zlib: { level: 9 }, // compression level
225 });
226 } else {
227 // tar.gz
228 archive = new Archiver('tar', {
229 gzip: true,
230 gzipOptions: {
231 level: 9, // compression level
232 },
233 });
234 }
235 if (serveUncommitted) {
236 // don't cache
237 archive = await archiveWorkingDir(repPath, archive);
238 } else {
239 archive = await archiveGitTree(repPath, tree, archive);
240 }
241
242 return new Promise((resolve, reject) => {
243 if (serveUncommitted) {
244 // don't cache
245 archive.finalize();
246 resolve(archive);
247 } else {
248 // cache archive file
249 archive.pipe(fse.createWriteStream(archiveFilePath)) // lgtm [js/path-injection]
250 .on('finish', () => resolve(fse.createReadStream(archiveFilePath))) // lgtm [js/path-injection]
251 .on('error', (err) => reject(err));
252 archive.finalize();
253 }
254 });
255 })
256 .then((archiveStream) => {
257 const mimeType = archiveFormat === 'zip' ? 'application/zip' : 'application/x-gzip';
258 res.writeHead(200, {
259 'Content-Type': mimeType,
260 'Content-Disposition': `attachment; filename=${archiveFileName}`,
261 });
262 archiveStream.pipe(res);
263 })
264 .catch((err) => {
265 debug(`[archiveHandler] code: ${err.code} message: ${err.message} stack: ${err.stack}`);
266 next(err);
267 });
268 };
269}
270module.exports = createMiddleware;