diff options
author | HampusM <hampus@hampusmat.com> | 2021-06-05 19:37:52 +0200 |
---|---|---|
committer | HampusM <hampus@hampusmat.com> | 2021-06-05 19:37:52 +0200 |
commit | 4da3272bf7893760f6710c9a1ec7de02358136e6 (patch) | |
tree | 92eb961bf20a7ef9f7c0650ba288baf512986fca /packages/server/src | |
parent | 4e3074dfd752dd52951d300090c642aee76cfaac (diff) |
Reorganized into a monorepo, refactored the frontend again, goodbye Parcel
Diffstat (limited to 'packages/server/src')
-rw-r--r-- | packages/server/src/api/git.js | 412 | ||||
-rw-r--r-- | packages/server/src/api/util.js | 45 | ||||
-rw-r--r-- | packages/server/src/api/v1.js | 136 | ||||
-rw-r--r-- | packages/server/src/app.js | 98 |
4 files changed, 691 insertions, 0 deletions
diff --git a/packages/server/src/api/git.js b/packages/server/src/api/git.js new file mode 100644 index 0000000..80b808f --- /dev/null +++ b/packages/server/src/api/git.js @@ -0,0 +1,412 @@ +const { formatDistance } = require('date-fns'); +const fs = require('fs'); +const git = require("nodegit"); +const zlib = require("zlib"); +const { spawn } = require('child_process'); +const whatwg = require("whatwg-url"); +const path = require("path"); + +function addRepoDirSuffix(repo_name) +{ + if(!repo_name.endsWith(".git")) { + return repo_name + ".git"; + } + return repo_name; +} + +async function getLog(base_dir, repo_name) +{ + const repo = await git.Repository.openBare(`${base_dir}/${repo_name}`); + + const walker = git.Revwalk.create(repo); + walker.pushHead(); + + const raw_commits = await walker.getCommitsUntil(() => true); + + const commits = Promise.all(raw_commits.map(async commit => ({ + commit: commit.sha(), + author_full: commit.author().toString(), + author_name: commit.author().name(), + author_email: commit.author().email(), + date: commit.date(), + message: commit.message().replace(/\n/g, ""), + insertions: (await (await commit.getDiff())[0].getStats()).insertions(), + deletions: (await (await commit.getDiff())[0].getStats()).deletions(), + files_changed: (await (await commit.getDiff())[0].getStats()).filesChanged() + }))); + + return await commits; +} + +async function getTimeSinceLatestCommit(base_dir, repo_name) +{ + const repo = await git.Repository.openBare(`${base_dir}/${repo_name}`); + const master_commit = await repo.getMasterCommit(); + + return formatDistance(new Date(), master_commit.date()); +} + +function getRepoFile(base_dir, repo, file) +{ + return new Promise(resolve => + { + fs.readFile(`${base_dir}/${repo}/${file}`, async (err, content) => + { + if(!err) { + resolve(content.toString().replace(/\n/g, "")); + return; + } + resolve(""); + }); + }); +} + +function getRepos(base_dir) +{ + return new Promise((resolve) => + { + fs.readdir(base_dir, (err, dir_content) => + { + if(err) { + resolve({ "error": err }); + return; + } + + dir_content.filter(repo => repo.endsWith(".git")).reduce((acc, repo) => + { + return acc.then((repos) => + { + return getRepoFile(base_dir, repo, "description").then((description) => + { + return getRepoFile(base_dir, repo, "owner").then((owner) => + { + return getTimeSinceLatestCommit(base_dir, repo).then((last_commit_date) => + { + repos[repo.slice(0, -4)] = { "description": description, "owner": owner, "last_updated": last_commit_date }; + return repos; + }); + }); + }); + }); + }, Promise.resolve({})).then((repos) => + { + resolve(repos); + }); + }); + }); +} + +function parseHunkAddDel(hunk) +{ + let new_lines = []; + let deleted_lines = []; + + hunk.forEach((line, index) => + { + if(line.charAt(0) === '+') { + hunk[index] = line.slice(1); + new_lines.push(index); + } + else if(line.charAt(0) === '-') { + hunk[index] = line.slice(1); + deleted_lines.push(index); + } + }); + + return { new: new_lines, deleted: deleted_lines, hunk: hunk.join("\n") }; +} + +async function getCommit(base_dir, repo_name, commit_oid) +{ + repo_name = addRepoDirSuffix(repo_name); + + const repo = await git.Repository.openBare(`${base_dir}/${repo_name}`); + const commit = await repo.getCommit(commit_oid); + const diff = (await commit.getDiff())[0]; + const all_patches = (await diff.toBuf(1)).split('\n'); + + // Get the count of lines for all of patches's headers + const patch_headers = (await diff.toBuf(2)).split('\n'); + const patch_header_data = await patch_headers.reduce((acc, line, index) => + { + return acc.then((arr) => + { + if(/^diff --git/.test(line)) { + arr[0].push(all_patches.indexOf(line)); + + if(arr[2] != undefined) { + arr[1].push(patch_headers.slice(arr[2], index).length); + } + arr[2] = index; + } + else if(index == patch_headers.length - 1 && arr[2] != undefined) { + arr[1].push(patch_headers.slice(arr[2], index).length); + } + return arr; + }); + }, Promise.resolve([ [], [], undefined ])); + + console.log(patch_header_data); + + const patches = await diff.patches(); + const parsed_patches = patches.reduce((acc, patch, patch_index) => + { + return acc.then((arr) => + { + return patch.hunks().then((hunks) => + { + const patch_start = patch_header_data[0][patch_index] + patch_header_data[1][patch_index]; + const patch_end = (patch_header_data[0][patch_index + 1] !== undefined) ? patch_header_data[0][patch_index + 1] : all_patches.length - 1; + const patch_content = all_patches.slice(patch_start, patch_end); + + const line_lengths = patch_content.map((line) => line.length).reduce((acc, length) => acc + length); + + if(patch_content.length > 5000 || line_lengths > 5000) { + console.log("Too large!"); + + arr.push({ + from: patch.oldFile().path(), + to: patch.newFile().path(), + additions: patch.lineStats()["total_additions"], + deletions: patch.lineStats()["total_deletions"], + too_large: true, + hunks: null + }); + return arr; + } + + // Go through all of the patch's hunks + // Patches are split into parts of where in the file the change is made. Those parts are called hunks. + return hunks.reduce((acc, hunk, hunk_index) => + { + return acc.then((hunks_data) => + { + const hunk_header = hunk.header(); + const hunk_header_index = patch_content.indexOf(hunk_header.replace(/\n/g, "")); + + if(hunks_data[0] !== undefined) { + const prev_hunk = hunks[hunk_index - 1]; + hunks_data[1].push(Object.assign({ + new_start: prev_hunk.newStart(), + new_lines: prev_hunk.newLines(), + old_start: prev_hunk.oldStart(), + old_lines: prev_hunk.oldLines() + }, parseHunkAddDel(patch_content.slice(hunks_data[0], hunk_header_index)))); + + hunks_data[2] = hunks_data + patch_content.slice(hunks_data[0], hunk_header_index).length; + } + + hunks_data[0] = hunk_header_index; + return hunks_data; + }); + }, Promise.resolve([ undefined, [], 0 ])).then((hunks_data) => + { + const prev_hunk = hunks[hunks.length - 1]; + hunks_data[1].push(Object.assign({ + new_start: prev_hunk.newStart(), + new_lines: prev_hunk.newLines(), + old_start: prev_hunk.oldStart(), + old_lines: prev_hunk.oldLines() + }, parseHunkAddDel(patch_content.slice(hunks_data[0], patch_end)))); + + arr.push({ + from: patch.oldFile().path(), + to: patch.isDeleted() ? "/dev/null" : patch.newFile().path(), + additions: patch.lineStats()["total_additions"], + deletions: patch.lineStats()["total_deletions"], + too_large: false, + hunks: hunks_data[1] + }); + + return arr; + }); + }); + }); + }, Promise.resolve([])); + + return { + hash: commit.sha(), + author: commit.author().toString(), + message: commit.message(), + date: commit.date(), + patches: await parsed_patches + }; +} + +async function doesCommitExist(base_dir, repo_name, commit_oid) +{ + const repo = await git.Repository.openBare(`${base_dir}/${repo_name}`); + + try { + await repo.getCommit(commit_oid); + return true; + } + catch { + return false; + } +} + +function connectToGitHTTPBackend(base_dir, req, reply) +{ + const url_path = req.url.replace(req.params.repo, req.params.repo + ".git"); + const repo = req.params.repo + ".git"; + const repo_path = path.join(base_dir, repo); + + req = req.headers['Content-Encoding'] == 'gzip' ? req.pipe(zlib.createGunzip()) : req; + + const parsed_url = new whatwg.URL(`${req.protocol}://${req.hostname}${url_path}`); + const url_path_parts = parsed_url.pathname.split('/'); + + let service; + let info = false; + + if(/\/info\/refs$/.test(parsed_url.pathname)) { + service = parsed_url.searchParams.get("service"); + info = true; + } + else { + service = url_path_parts[url_path_parts.length-1]; + } + + const content_type = `application/x-${service}-${info ? "advertisement" : "result"}`; + + if(/\.\/|\.\./.test(parsed_url.pathname)) { + reply.header("Content-Type", content_type); + reply.code(404).send("Git repository not found!\n"); + return; + } + + if(service !== 'git-upload-pack') { + reply.header("Content-Type", content_type); + reply.code(403).send("Access denied!\n"); + return; + } + + reply.raw.writeHead(200, { "Content-Type": content_type }); + + const spawn_args = [ "--stateless-rpc", repo_path ]; + + if(info) { + spawn_args.push("--advertise-refs"); + } + + const git_pack = spawn(service, spawn_args); + + if(info) { + const s = '# service=' + service + '\n'; + const n = (4 + s.length).toString(16); + reply.raw.write(Buffer.from((Array(4 - n.length + 1).join('0') + n + s) + '0000')); + } + else { + req.body.on("data", (data) => git_pack.stdin.write(data)); + req.body.on("close", () => git_pack.stdin.end()); + } + git_pack.on("error", (err) => console.log(err)); + git_pack.stderr.on("data", (stderr) => console.log(stderr)); + + git_pack.stdout.on("data", (data) => + { + reply.raw.write(data); + }); + + git_pack.on("close", () => reply.raw.end()); +} + +async function getTree(base_dir, repo_name, tree_path) +{ + repo_name = addRepoDirSuffix(repo_name); + + const repo = await git.Repository.openBare(`${base_dir}/${repo_name}`); + const master_commit = await repo.getMasterCommit(); + + const tree = await master_commit.getTree(); + + let entries; + if(tree_path) { + try { + const path_entry = await tree.getEntry(tree_path); + + if(path_entry.isBlob()) { + return { type: "blob", content: (await path_entry.getBlob()).content().toString() }; + } + + entries = await (await path_entry.getTree()).entries(); + } + catch(err) { + if(err.errno === -3) { + return { error: 404 }; + } + return { error: 500 }; + } + } + else { + entries = tree.entries(); + } + + return { type: "tree", tree: await entries.reduce((acc, entry) => + { + return acc.then((obj) => + { + return getTreeEntryLastCommit(repo, entry).then((last_commit) => + { + obj[path.parse(entry.path()).base] = { + oid: entry.oid(), + type: entry.isBlob() ? "blob" : "tree", + last_commit: { + id: last_commit.id, + message: last_commit.message, + time: last_commit.time + } + }; + return obj; + }); + }); + }, Promise.resolve({})) }; +} + +async function getTreeEntryLastCommit(repo, tree_entry) +{ + const walker = git.Revwalk.create(repo); + walker.pushHead(); + + const raw_commits = await walker.getCommitsUntil(() => true); + + return raw_commits.reduce((acc, commit) => + { + return acc.then((obj) => + { + if(Object.keys(obj).length == 0) { + return commit.getDiff().then((diffs) => + { + return diffs[0].patches().then((patches) => + { + let matching_path_patch; + if(tree_entry.isBlob()) { + matching_path_patch = patches.find((patch) => patch.newFile().path() === tree_entry.path()); + } + else { + matching_path_patch = patches.find((patch) => path.parse(patch.newFile().path()).dir.startsWith(tree_entry.path())); + } + + if(matching_path_patch) { + obj.id = commit.sha(); + obj.message = commit.message().replace(/\n/g, ""); + obj.time = commit.date(); + } + return obj; + }); + }); + } + + return obj; + }); + }, Promise.resolve({})); +} + +module.exports.getLog = getLog; +module.exports.getRepos = getRepos; +module.exports.getRepoFile = getRepoFile; +module.exports.getCommit = getCommit; +module.exports.doesCommitExist = doesCommitExist; +module.exports.connectToGitHTTPBackend = connectToGitHTTPBackend; +module.exports.getTree = getTree;
\ No newline at end of file diff --git a/packages/server/src/api/util.js b/packages/server/src/api/util.js new file mode 100644 index 0000000..aa31296 --- /dev/null +++ b/packages/server/src/api/util.js @@ -0,0 +1,45 @@ +const fs = require("fs"); +const git = require("./git"); + +function verifyRepoName(dirty, base_dir) +{ + return new Promise((resolve) => + { + const is_valid_repo_name = /^[a-zA-Z0-9\\.\-_]+$/.test(dirty); + if(!is_valid_repo_name) { + resolve("ERR_REPO_REGEX"); + } + + fs.readdir(base_dir, (err, dir_content) => + { + if(err) { + resolve("ERR_REPO_NOT_FOUND"); + } + + dir_content = dir_content.filter(repo => repo.endsWith(".git")); + if(!dir_content.includes(dirty + ".git")) { + resolve("ERR_REPO_NOT_FOUND"); + } + + resolve(true); + }); + }); +} + +async function verifyCommitID(base_dir, repo, dirty) +{ + if(!/^[a-fA-F0-9]+$/.test(dirty)) { + return "ERR_COMMIT_REGEX"; + } + + const commit_exists = await git.doesCommitExist(base_dir, repo, dirty); + + if(!commit_exists) { + return "ERR_COMMIT_NOT_FOUND"; + } + + return true; +} + +module.exports.verifyRepoName = verifyRepoName; +module.exports.verifyCommitID = verifyCommitID;
\ No newline at end of file diff --git a/packages/server/src/api/v1.js b/packages/server/src/api/v1.js new file mode 100644 index 0000000..25a8019 --- /dev/null +++ b/packages/server/src/api/v1.js @@ -0,0 +1,136 @@ +const git = require("./git"); +const util = require("./util"); + +module.exports = function (fastify, opts, done) +{ + fastify.route({ + method: "GET", + path: "/info", + handler: (req, reply) => + { + reply.send({ data: opts.config.settings }); + } + }); + fastify.route({ + method: "GET", + path: "/repos", + handler: async (req, reply) => + { + let repos = await git.getRepos(opts.config.settings.base_dir); + + if(repos["error"]) { + reply.code(500).send({ error: "Internal server error!" }); + return; + } + + reply.send({ data: repos }); + } + }); + + fastify.route({ + method: "GET", + path: "/repos/:repo", + handler: async (req, reply) => + { + const repo_verification = await util.verifyRepoName(req.params.repo, opts.config.settings.base_dir); + if(repo_verification !== true) { + if(repo_verification === "ERR_REPO_REGEX") { + reply.code(400).send({ error: "Unacceptable git repository name!" }); + } + else if(repo_verification === "ERR_REPO_NOT_FOUND") { + reply.code(404).send({ error: "Git repository not found!" }); + } + } + + const repo = `${req.params.repo}.git`; + const desc = await git.getRepoFile(opts.config.settings.base_dir, repo, "description"); + + reply.send({ data: { name: req.params.repo, description: desc } }); + } + }); + + fastify.register((fastify_repo, opts_repo, done_repo) => + { + fastify_repo.addHook("onRequest", async (req, reply) => + { + const repo_verification = await util.verifyRepoName(req.params.repo, opts.config.settings.base_dir); + if(repo_verification !== true) { + if(repo_verification === "ERR_REPO_REGEX") { + reply.code(400).send({ error: "Unacceptable git repository name!" }); + } + else if(repo_verification === "ERR_REPO_NOT_FOUND") { + reply.code(404).send({ error: "Git repository not found!" }); + } + } + }); + + fastify_repo.route({ + method: "GET", + path: "/log", + handler: async (req, reply) => + { + const log = await git.getLog(opts.config.settings.base_dir, req.params.repo + ".git"); + + if(log["error"]) { + if(typeof log["error"] === "string") { + reply.code(500).send({ error: log["error"] }); + } + + switch(log["error"]) { + case 404: + reply.code(404).send({ error: "Git repository not found!" }); + } + + return; + } + reply.send({ data: log }); + } + }); + + fastify_repo.route({ + method: "GET", + path: "/log/:commit", + handler: async (req, reply) => + { + const commit_verification = await util.verifyCommitID(opts.config.settings.base_dir, req.params.repo + ".git", req.params.commit); + if(!commit_verification !== true) { + if(commit_verification === "ERR_COMMIT_REGEX") { + reply.code(400).send({ error: "Unacceptable commit id!" }); + } + else if(commit_verification === "ERR_COMMIT_NOT_FOUND") { + reply.code(404).send({ error: "Commit not found!" }); + } + } + + const commit = await git.getCommit(opts.config.settings.base_dir, req.params.repo, req.params.commit); + + reply.send({ data: commit }); + } + }); + + fastify_repo.route({ + method: "GET", + path: "/tree", + handler: async (req, reply) => + { + const tree_path = (req.query.length !== 0 && req.query.path) ? req.query.path : null; + + const tree = await git.getTree(opts.config.settings.base_dir, req.params.repo, tree_path); + + if(tree.error) { + if(tree.error === 404) { + reply.code(404).send({ error: "Path not found" }); + } + else { + reply.code(500).send({ error: "Internal server error" }); + } + } + reply.send({ data: tree }); + } + }); + + done_repo(); + }, { prefix: "/repos/:repo" }); + + done(); +};
\ No newline at end of file diff --git a/packages/server/src/app.js b/packages/server/src/app.js new file mode 100644 index 0000000..79ece82 --- /dev/null +++ b/packages/server/src/app.js @@ -0,0 +1,98 @@ +const fastify = require("fastify")(); +const api = require("./api/v1"); +const yaml = require('js-yaml'); +const fs = require('fs'); +const { exit } = require("process"); +const git = require("./api/git"); +const path = require("path"); + +const settings = yaml.load(fs.readFileSync(__dirname + "/../../../settings.yml", 'utf8')); +const settings_keys = Object.keys(settings); + +const mandatory_settings = [ "host", "port", "dev_port", "title", "about", "base_dir", "production" ]; + +// Make sure that all the required settings are present +const settings_not_included = mandatory_settings.filter(x => !settings_keys.includes(x)); +if(settings_not_included.length !== 0) { + console.log(`Error: settings.yml is missing ${(settings_not_included.length > 1) ? "keys" : "key"}:`); + console.log(settings_not_included.join(", ")); + exit(1); +} + +// Make sure that there's not an excessive amount of settings +const mandatory_not_included = settings_keys.filter(x => !mandatory_settings.includes(x)); +if(mandatory_not_included.length !== 0) { + console.log(`Error: settings.yml includes ${(mandatory_not_included.length > 1) ? "pointless keys" : "a pointless key"}:`); + console.log(mandatory_not_included.join(", ")); + exit(1); +} + +// Make sure that the base directory specified in the settings actually exists +try { + fs.readdirSync(settings["base_dir"]); +} +catch { + console.error(`Error: Tried opening the base directory. No such directory: ${settings["base_dir"]}`); + exit(1); +} + +fastify.setNotFoundHandler({ + preValidation: (req, reply, done) => done(), + preHandler: (req, reply, done) => done() +}, function (req, reply) +{ + reply.send("404: Not found"); +}); + +if(settings.production) { + fastify.register(require('fastify-static'), { root: path.join(__dirname, '/../../../dist') }); + + fastify.route({ + method: "GET", + path: "/", + handler: (req, reply) => reply.sendFile("index.html") + }); +} + +fastify.addContentTypeParser("application/x-git-upload-pack-request", (req, payload, done) => done(null, payload)); + +fastify.register(api, { prefix: "/api/v1", config: { settings: settings } }); + +fastify.route({ + method: "GET", + path: "/:repo([a-zA-Z0-9\\.\\-_]+)/info/refs", + handler: (req, reply) => + { + if(!req.query.service) { + reply.code(403).send("Missing service query parameter\n"); + return; + } + else if(req.query.service !== "git-upload-pack") { + reply.code(403).send("Access denied!\n"); + return; + } + else if(Object.keys(req.query).length !== 1) { + reply.header("Content-Type", "application/x-git-upload-pack-advertisement"); + reply.code(403).send("Too many query parameters!\n"); + return; + } + + git.connectToGitHTTPBackend(settings["base_dir"], req, reply); + } +}); + +fastify.route({ + method: "POST", + path: "/:repo([a-zA-Z0-9\\.\\-_]+)/git-upload-pack", + handler: (req, reply) => git.connectToGitHTTPBackend(settings["base_dir"], req, reply) +}); + +fastify.listen(settings.port, settings.host, (err, addr) => +{ + if(err) { + console.error(err); + exit(1); + } + + console.log(`App is running on ${addr}`); +});
\ No newline at end of file |