// Copyright © 2023 Dean Lee 'use strict'; const express = require('express'); const next = require('next'); const fs = require('fs'); const sqlite3 = require('sqlite3').verbose(); const nodeGit = require('nodegit'); const segFaultHandler = require('segfault-handler'); const path = require('path'); const repositoryDirectory = './repositories/'; const dbFile = './gitcub.db'; const uncaughtExceptionMsg = 'An uncaught exception has occurred.'; const sizeLimitErrMsg = 'Something went wrong in the request body size filtering stage.'; const nullByteMsg = 'Request rejected because the url contained a null byte. Possible hack attempt.'; const dbFailMsg = 'The database could not be opened.'; const dbDataFailMsg = 'The repository data could not be retrieved from the database.'; const repoFailMsg = 'The repository could not be opened.'; const blobFailMsg = 'The blob could not be opened.'; const treeFailMsg = 'The tree could not be opened.'; const commitFailMsg = 'The commit could not be opened.'; const historyFailMsg = 'The commit history could not be retrieved.'; const objectFailMsg = 'The git object could not be retrieved.'; const referencesFailMsg = 'The references could not be retrieved.'; const masterOrTreeFailMsg = 'The master commit or its tree could not be opened.'; const dev = process.env.NODE_ENV !== 'production'; const port = 3000; const app = next({ dev }); const handle = app.getRequestHandler(); segFaultHandler.registerHandler('crash.log'); const directoryExists = (path) => fs.existsSync(path) ? fs.statSync(path).isDirectory() : false; const repositoryExists = (name, rows) => ( directoryExists(`${repositoryDirectory}${name}.git`) && rows.some((x) => x.name === name) ); const getEntryNamesSorted = (entries) => { const entryNamesSorted = { trees: entries.filter((entry) => entry.isTree()).map((entry) => entry.name()), blobs: entries.filter((entry) => entry.isBlob()).map((entry) => entry.name()), }; return entryNamesSorted; }; const getContentType = (filename) => { switch (path.extname(filename)) { case '.pdf': return 'application/pdf'; case '.gif': return 'image/gif'; case '.jpeg': case '.jpg': return 'image/jpeg'; case '.png': return 'image/png'; case '.tiff': case '.tif': return 'image/tiff'; default: return 'application/octet-stream'; } }; const setHeadersAndSend = (res, content) => { res.set('X-Content-Type-Options', 'nosniff'); res.set('Content-Security-Policy', 'frame-ancestors \'none\''); res.send(content); }; const sendBinary = (res, filename, content) => { const contentType = getContentType(filename); res.set('content-type', contentType); setHeadersAndSend(res, content); }; const sendPlainText = (res, text) => { res.set('content-type', 'text/plain'); setHeadersAndSend(res, text); }; const sendAPIResult = (res, resultType, resultPath, resultContent = '') => { const result = { resultType: resultType, resultPath: resultPath, resultContent: resultContent, }; res.json(result); }; const sendNotFound = (res, isAPIRequest) => { if (isAPIRequest) { sendAPIResult(res, 'notFound', []); } else { res.status(404).end(); } }; const sendError = (res, isAPIRequest, logMessage) => { console.error(logMessage); if (isAPIRequest) { sendAPIResult(res, 'error', []); } else { res.status(500).end(); } }; const handleError = (res, isAPIRequest, message, err) => { console.error(err); sendError(res, isAPIRequest, message); }; const sendRootContents = ( res, pathArray, entryNamesSorted, readmeExists, readmeBlob = '', readmeName = '', ) => { const rootContents = { entryNamesSorted: entryNamesSorted, readmeExists: readmeExists, readmeBlob: readmeBlob, readmeName: readmeName, }; sendAPIResult(res, 'rootDirectory', pathArray, rootContents); }; const showBlob = (res, pathArray, depth, blob, isAPIRequest) => { if (blob.isBinary()) { if (isAPIRequest) { sendAPIResult(res, 'binaryBlob', pathArray); } else { const buffer = blob.content(); sendBinary(res, pathArray[depth], buffer); } } else { const text = blob.toString(); if (isAPIRequest) { sendAPIResult(res, 'textBlob', pathArray, text); } else { sendPlainText(res, text); } } }; const tryShowBlob = (res, pathArray, depth, entries, isAPIRequest) => { const repoRequest = pathArray[1]; const isMatchingBlob = (x) => x.name() === pathArray[depth] && x.isBlob(); if (pathArray.length === depth + 1) { if (entries.some(isMatchingBlob)) { if (repoRequest === 'blob' || repoRequest === 'raw') { entries.find(isMatchingBlob).getBlob() .then((blob) => { showBlob(res, pathArray, depth, blob, isAPIRequest); }) .catch((err) => { handleError(res, isAPIRequest, blobFailMsg, err); }); } else { sendNotFound(res, isAPIRequest); } } else { sendNotFound(res, isAPIRequest); } } else { sendNotFound(res, isAPIRequest); } }; const resolveDirectoryPath = (res, pathArray, tree, isAPIRequest) => { const recursion = (depth, tree) => { const repoRequest = pathArray[1]; const entries = tree.entries(); const isMatchingTree = (x) => x.name() === pathArray[depth] && x.isTree(); if (pathArray.length > depth) { if (entries.some(isMatchingTree)) { entries.find(isMatchingTree).getTree() .then((tree) => { const newDepth = depth + 1; recursion(newDepth, tree); }) .catch((err) => { handleError(res, isAPIRequest, treeFailMsg, err); }); } else { tryShowBlob(res, pathArray, depth, entries, isAPIRequest); } } else { if (repoRequest === 'tree') { const entryNamesSorted = getEntryNamesSorted(entries); sendAPIResult(res, 'nonRootDirectory', pathArray, entryNamesSorted); } else { sendNotFound(res, isAPIRequest); } } }; recursion(3, tree); }; const showRoot = (res, pathArray, tree) => { const entries = tree.entries(); const entryNamesSorted = getEntryNamesSorted(entries); if (entryNamesSorted.blobs.some((x) => x.toLowerCase() === 'readme.md')) { const readme = entries.find((x) => x.name().toLowerCase() === 'readme.md' && x.isBlob()); readme.getBlob() .then((blob) => { if (!blob.isBinary()) { sendRootContents( res, pathArray, entryNamesSorted, true, blob.toString(), readme.name(), ); } else { sendRootContents(res, pathArray, entryNamesSorted, false); } }) .catch((err) => { handleError(res, isAPIRequest, blobFailMsg, err); }); } else { sendRootContents(res, pathArray, entryNamesSorted, false); } }; const showCommits = (res, pathArray, commit, isAPIRequest) => { const commitEmitter = commit.history(); commitEmitter.on('end', (commits) => { const commitObjs = commits.map((item) => { const obj = { id: item.id().tostrS(), message: item.message(), }; return obj; }); sendAPIResult(res, 'commits', pathArray, commitObjs); }); commitEmitter.on('error', (err) => { handleError(res, isAPIRequest, historyFailMsg, err); }); commitEmitter.start(); }; const openCommit = (res, pathArray, commitID, repo, isAPIRequest) => { repo.getCommit(commitID) .then((commit) => { const repoRequest = pathArray[1]; if (repoRequest === 'commits') { if (pathArray.length === 3) { showCommits(res, pathArray, commit, isAPIRequest); } else { sendNotFound(res, isAPIRequest); } } else { commit.getTree() .then((tree) => { resolveDirectoryPath(res, pathArray, tree, isAPIRequest); }) .catch((err) => { handleError(res, isAPIRequest, treeFailMsg, err); }); } }) .catch((err) => { handleError(res, isAPIRequest, commitFailMsg, err); }); }; const tryParseCommitID = (res, pathArray, refs, repo, isAPIRequest) => { const branchNames = refs.filter((x) => x.isBranch()).map((x) => x.shorthand()); const recursion = (index) => { const commitName = pathArray[2]; const branchName = branchNames[index]; repo.getBranchCommit(branchName) .then((commit) => { const commitEmitter = commit.history(); commitEmitter.on('end', (commits) => { if (commits.some((x) => x.id().tostrS() === commitName)) { openCommit(res, pathArray, commitName, repo, isAPIRequest); } else if (branchNames.length > index + 1) { recursion(index + 1); } else { sendNotFound(res, isAPIRequest); } }); commitEmitter.on('error', (err) => { handleError(res, isAPIRequest, historyFailMsg, err); }); commitEmitter.start(); }) .catch((err) => { handleError(res, isAPIRequest, commitFailMsg, err); }); }; if (branchNames.length > 0) { recursion(0); } else { sendNotFound(res, isAPIRequest); } }; const tryParseTag = (res, pathArray, refs, repo, isAPIRequest) => { const commitName = pathArray[2]; const isMatchingTag = (x) => x.isTag() && x.shorthand() === commitName; if (refs.some(isMatchingTag)) { const ref = refs.find(isMatchingTag); // resolve both lightweight and annotated tags ref.peel(nodeGit.Object.TYPE.COMMIT) .then((object) => { openCommit(res, pathArray, object.id(), repo, isAPIRequest); }) .catch((err) => { handleError(res, isAPIRequest, objectFailMsg, err); }); } else { tryParseCommitID(res, pathArray, refs, repo, isAPIRequest); } }; const tryParseBranch = (res, pathArray, repo, isAPIRequest) => { if (pathArray.length > 2) { const commitName = pathArray[2]; repo.getReferences() .then((refs) => { const isMatchingBranch = (x) => x.isBranch() && x.shorthand() === commitName; if (refs.some(isMatchingBranch)) { const target = refs.find(isMatchingBranch).target(); openCommit(res, pathArray, target, repo, isAPIRequest); } else { tryParseTag(res, pathArray, refs, repo, isAPIRequest); } }) .catch((err) => { handleError(res, isAPIRequest, referencesFailMsg, err); }); } else { sendNotFound(res, isAPIRequest); } }; const tryShowBranchesOrTags = (res, pathArray, repo, isAPIRequest) => { if (pathArray.length === 2) { repo.getReferences() .then((refs) => { const branchNames = refs.filter((x) => x.isBranch()).map((x) => x.shorthand()); const tagNames = refs.filter((x) => x.isTag()).map((x) => x.shorthand()); const repoRequest = pathArray[1]; if (repoRequest === 'branches') { sendAPIResult(res, 'branches', pathArray, branchNames); } else { sendAPIResult(res, 'tags', pathArray, tagNames); } }) .catch((err) => { handleError(res, isAPIRequest, referencesFailMsg, err); }); } else { sendNotFound(res, isAPIRequest); } }; const tryParseRepoRequest = (res, pathArray, repo, isAPIRequest) => { const repoRequest = pathArray[1]; switch (repoRequest) { case 'commits': case 'tree': case 'blob': case 'raw': tryParseBranch(res, pathArray, repo, isAPIRequest); break; case 'branches': case 'tags': tryShowBranchesOrTags(res, pathArray, repo, isAPIRequest); break; default: sendNotFound(res, isAPIRequest); } }; const tryShowRoot = (res, pathArray, repo, isAPIRequest) => { if (pathArray.length === 1) { repo.getMasterCommit() .then((commit) => commit.getTree()) .then((tree) => { showRoot(res, pathArray, tree); }) .catch((err) => { handleError(res, isAPIRequest, masterOrTreeFailMsg, err); }); } else { tryParseRepoRequest(res, pathArray, repo, isAPIRequest); } }; const tryParseRepository = (res, pathArray, rows, isAPIRequest) => { const repoName = pathArray[0]; if (repositoryExists(repoName, rows)) { const pathToRepo = `${repositoryDirectory}${repoName}.git`; nodeGit.Repository.openBare(pathToRepo) .then((repo) => { tryShowRoot(res, pathArray, repo, isAPIRequest); }) .catch((err) => { handleError(res, isAPIRequest, repoFailMsg, err); }); } else { sendNotFound(res, isAPIRequest); } }; const tryShowHome = (res, pathArray, rows, isAPIRequest) => { if (pathArray.length === 0) { sendAPIResult(res, 'home', pathArray, rows); } else { tryParseRepository(res, pathArray, rows, isAPIRequest); } }; const handleBackendRequest = (req, res, isAPIRequest) => { const db = new sqlite3.Database(dbFile, sqlite3.OPEN_READONLY, (err) => { if (err) { handleError(res, isAPIRequest, dbFailMsg, err); } }); db.all('select name from repositories', (err, rows) => { db.close(); if (!err) { if (req.originalUrl.indexOf('\0') === -1) { const pathArray = req.path.split('/').filter((x) => x.length > 0); // pathArray at this point must have at least 1 element due to routing rules const pathArrayClipped = isAPIRequest ? pathArray.slice(1) : pathArray; tryShowHome(res, pathArrayClipped, rows, isAPIRequest); } else { sendError(res, isAPIRequest, nullByteMsg); } } else { handleError(res, isAPIRequest, dbDataFailMsg, err); } }); }; app.prepare() .then(() => { const server = express(); // limit the size of request bodies to protect against DOS attack server.use((req, res, next) => { let bodySize = 0; req.on('data', (chunk) => { bodySize = bodySize + chunk.length; if (bodySize > 1000) { res.status(413).end(); } }); req.on('end', () => { next(); }); req.on('error', (err) => { console.error(err); console.error(sizeLimitErrMsg); res.status(500).end(); }); }); server.get('/api/*', (req, res) => { handleBackendRequest(req, res, true); }); server.get(/^\/[^/]*\/raw(\/|$)/, (req, res) => { handleBackendRequest(req, res, false); }); server.get('*', (req, res) => { return handle(req, res); }); server.listen(port, () => { console.log(`App listening on port ${port}`); }); server.on('uncaughtException', (err) => { handleError(res, isAPIRequest, uncaughtExceptionMsg, err); }); }) .catch((ex) => { console.error(ex.stack); process.exit(1); });