From 86395bd219b770133cb80d4bda4efc9155a4eef8 Mon Sep 17 00:00:00 2001 From: HampusM Date: Mon, 7 Jun 2021 20:54:26 +0200 Subject: Refactored the backend. It's written in typescript now. --- packages/server/src/api/git.js | 412 ---------------------------------------- packages/server/src/api/git.ts | 395 ++++++++++++++++++++++++++++++++++++++ packages/server/src/api/util.js | 45 ----- packages/server/src/api/util.ts | 79 ++++++++ packages/server/src/api/v1.js | 136 ------------- packages/server/src/api/v1.ts | 123 ++++++++++++ packages/server/src/app.js | 98 ---------- packages/server/src/app.ts | 127 +++++++++++++ 8 files changed, 724 insertions(+), 691 deletions(-) delete mode 100644 packages/server/src/api/git.js create mode 100644 packages/server/src/api/git.ts delete mode 100644 packages/server/src/api/util.js create mode 100644 packages/server/src/api/util.ts delete mode 100644 packages/server/src/api/v1.js create mode 100644 packages/server/src/api/v1.ts delete mode 100644 packages/server/src/app.js create mode 100644 packages/server/src/app.ts (limited to 'packages/server/src') diff --git a/packages/server/src/api/git.js b/packages/server/src/api/git.js deleted file mode 100644 index 80b808f..0000000 --- a/packages/server/src/api/git.js +++ /dev/null @@ -1,412 +0,0 @@ -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/git.ts b/packages/server/src/api/git.ts new file mode 100644 index 0000000..1f39692 --- /dev/null +++ b/packages/server/src/api/git.ts @@ -0,0 +1,395 @@ +import { ConvenientHunk, Repository, Revwalk, TreeEntry } from "nodegit"; +import { join, parse } from "path"; +import { readFile, readdir } from "fs"; +import { FastifyRequest } from "fastify"; +import { IncomingMessage } from "http"; +import { URL } from "whatwg-url"; +import { formatDistance } from "date-fns"; +import { spawn } from "child_process"; +import { verifyGitRequest } from "./util"; + +function addRepoDirSuffix(repo_name: string) { + return repo_name.endsWith(".git") ? repo_name : `${repo_name}.git`; +} + +function parseHunkAddDel(hunk: string[]) { + interface Lines { + new_lines: number[], + deleted_lines: number[] + } + + const lines = hunk.reduce((lines_obj: Lines, line, index) => { + if(line.charAt(0) === "+") { + hunk[index] = line.slice(1); + lines_obj.new_lines.push(index); + } + else if(line.charAt(0) === "-") { + hunk[index] = line.slice(1); + lines_obj.deleted_lines.push(index); + } + return lines_obj; + }, { new_lines: [], deleted_lines: [] }); + + return Object.assign(lines, { hunk: hunk.join("\n") }); +} + +function getPatchHeaderData(patch_headers: string[], all_patches: string[]) { + interface PatchHeaderData { + indexes: number[], + lengths: number[], + last: number | null + }; + + return patch_headers.reduce((patch_header_data, line, index) => { + // The start of a patch header + if((/^diff --git/u).test(line)) { + patch_header_data.indexes.push(all_patches.indexOf(line)); + + if(patch_header_data.last !== null) { + patch_header_data.lengths.push(patch_headers.slice(patch_header_data.last, index).length); + } + patch_header_data.last = index; + } + + // Include the last patch header when the end is reached + if(index === patch_headers.length - 1 && patch_header_data.last !== null) { + patch_header_data.lengths.push(patch_headers.slice(patch_header_data.last, index).length); + } + + return patch_header_data; + }, { indexes: [], lengths: [], last: null }); +} + +function getHunksData(hunks: ConvenientHunk[], patch_content: string[]) { + return hunks.reduce((hunks_data, hunk, hunk_index) => { + const hunk_header = hunk.header(); + const hunk_header_index = patch_content.indexOf(hunk_header.replace(/\n/gu, "")); + + if(hunks_data.prev !== null) { + const prev_hunk = hunks[hunk_index - 1]; + hunks_data.hunks.push({ + 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.prev, hunk_header_index)) + }); + } + + hunks_data.prev = hunk_header_index; + return hunks_data; + }, { prev: null, hunks: [] }); +} + +function Patch(patch, too_large, hunks) { + this.from = patch.oldFile().path(); + this.to = patch.newFile().path(); + this.additions = patch.lineStats()["total_additions"]; + this.deletions = patch.lineStats()["total_deletions"]; + this.too_large = too_large; + this.hunks = hunks; +} + +export type GitRequestInfo = { + repo: string, + url_path: string, + parsed_url: URL, + url_path_parts: string[], + is_discovery: boolean, + service: string | null, + content_type: string +}; + +function extractRequestInfo(req: FastifyRequest): GitRequestInfo { + const params: any = req.params; + + const repo = params.repo + ".git"; + const url_path = req.url.replace(params.repo, repo); + + const parsed_url = new URL(`${req.protocol}://${req.hostname}${url_path}`); + const url_path_parts = parsed_url.pathname.split("/"); + + const is_discovery = (/\/info\/refs$/u).test(parsed_url.pathname); + + const service = is_discovery ? parsed_url.searchParams.get("service") : url_path_parts[url_path_parts.length - 1]; + + const content_type = `application/x-${service}-${is_discovery ? "advertisement" : "result"}`; + + return { + repo, + url_path, + parsed_url, + is_discovery, + url_path_parts, + service, + content_type + }; +} + +async function getTreeEntryLastCommit(repo: Repository, tree_entry: TreeEntry) { + const walker = Revwalk.create(repo); + walker.pushHead(); + + interface LastTreeEntryCommit { + id: string | null, + message: string | null, + date: Date | null + }; + + const raw_commits = await walker.getCommitsUntil(() => true); + + return raw_commits.reduce((acc, commit) => acc.then(obj => { + if(obj.id === null) { + return commit.getDiff().then(diffs => diffs[0].patches().then(patches => { + let matching_path_patch = null; + if(tree_entry.isBlob()) { + matching_path_patch = patches.find(patch => patch.newFile().path() === tree_entry.path()); + } + else { + matching_path_patch = patches.find(patch => parse(patch.newFile().path()).dir.startsWith(tree_entry.path())); + } + + if(matching_path_patch) { + obj.id = commit.sha(); + obj.message = commit.message().replace(/\n/gu, ""); + obj.date = commit.date(); + } + return obj; + })); + } + + return obj; + }), Promise.resolve({ id: null, message: null, date: null })); +} + +export class Git { + base_dir: string; + + constructor(base_dir: string) { + this.base_dir = base_dir; + } + + async getLog(repo_name: string) { + const full_repo_name = addRepoDirSuffix(repo_name); + const repo = await Repository.openBare(`${this.base_dir}/${full_repo_name}`); + + const walker: Revwalk = Revwalk.create(repo); + walker.pushHead(); + + const raw_commits = await walker.getCommitsUntil(() => true); + + const commits = await 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/gu, ""), + 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 commits; + } + + async getTimeSinceLatestCommit(repo_name: string) { + const full_repo_name = addRepoDirSuffix(repo_name); + const repo = await Repository.openBare(`${this.base_dir}/${full_repo_name}`); + + const master_commit = await repo.getMasterCommit(); + + return formatDistance(new Date(), master_commit.date()); + } + + getRepoFile(repo_name: string, file: string) { + return new Promise(resolve => { + const full_repo_name = addRepoDirSuffix(repo_name); + readFile(`${this.base_dir}/${full_repo_name}/${file}`, (err, content) => { + if(!err) { + resolve(content.toString().replace(/\n/gu, "")); + return; + } + resolve(""); + }); + }); + } + + getRepos() { + return new Promise(resolve => { + readdir(this.base_dir, (err: Error, dir_content: string[]) => { + if(err) { + resolve({ "error": err }); + return; + } + + dir_content.filter(repo => repo.endsWith(".git")).reduce((acc, repo) => { + return acc.then((repos: any) => { + return this.getRepoFile(repo, "description").then(description => { + return this.getRepoFile(repo, "owner").then(owner => { + return this.getTimeSinceLatestCommit(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); + }); + }); + }); + } + + async getCommit(repo_name: string, commit_oid: string) { + const full_repo_name = addRepoDirSuffix(repo_name); + const repo = await Repository.openBare(`${this.base_dir}/${full_repo_name}`); + const commit = await repo.getCommit(commit_oid); + const diff = (await commit.getDiff())[0]; + const all_patches = (await diff.toBuf(1)).toString().split("\n"); + const patch_header_data = getPatchHeaderData((await diff.toBuf(2)).toString().split("\n"), all_patches); + + const parsed_patches = (await diff.patches()).reduce((acc, patch, patch_index) => { + return acc.then(arr => patch.hunks().then(hunks => { + const patch_start = patch_header_data.indexes[patch_index] + patch_header_data.lengths[patch_index]; + const patch_end = (typeof patch_header_data.indexes[patch_index + 1] === "undefined") ? all_patches.length - 1 : patch_header_data.indexes[patch_index + 1]; + const patch_content = all_patches.slice(patch_start, patch_end); + + const line_lengths = patch_content.map(line => line.length).reduce((result, length) => result + length); + + if(patch_content.length > 5000 || line_lengths > 5000) { + console.log("Too large!"); + + arr.push(new Patch(patch, true, null)); + return arr; + } + + const hunks_data = getHunksData(hunks, patch_content); + + const prev_hunk = hunks[hunks.length - 1]; + hunks_data.hunks.push({ + 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.prev, patch_end)) + }); + + arr.push(new Patch(patch, false, hunks_data.hunks)); + + return arr; + })); + }, Promise.resolve([])); + + return { + hash: commit.sha(), + author: commit.author().toString(), + message: commit.message(), + date: commit.date(), + patches: await parsed_patches + }; + } + + connectToGitHTTPBackend(req, reply) { + const request_info = extractRequestInfo(req); + + const valid_request = verifyGitRequest(request_info); + if(valid_request.success === false) { + reply.header("Content-Type", request_info.content_type); + reply.code(valid_request.code).send(valid_request.message); + return; + } + + reply.raw.writeHead(200, { "Content-Type": request_info.content_type }); + + const spawn_args = [ "--stateless-rpc", join(this.base_dir, request_info.repo) ]; + if(request_info.is_discovery) { + spawn_args.push("--advertise-refs"); + } + + const git_pack = spawn(request_info.service, spawn_args); + + if(request_info.is_discovery) { + const s = "# service=" + request_info.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 { + const request_body: IncomingMessage = req.raw; + + request_body.on("data", data => git_pack.stdin.write(data)); + request_body.on("close", () => git_pack.stdin.end()); + } + + git_pack.on("error", err => console.log(err)); + + git_pack.stderr.on("data", (stderr: Buffer) => console.log(stderr.toString())); + git_pack.stdout.on("data", data => reply.raw.write(data)); + + git_pack.on("close", () => reply.raw.end()); + } + + async getTree(repo_name, tree_path) { + const full_repo_name = addRepoDirSuffix(repo_name); + const repo = await Repository.openBare(`${this.base_dir}/${full_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[parse(entry.path()).base] = { + oid: entry.oid(), + type: entry.isBlob() ? "blob" : "tree", + last_commit: { + id: last_commit.id, + message: last_commit.message, + date: last_commit.date + } + }; + return obj; + }); + }); + }, Promise.resolve({})) + }; + } + + async doesCommitExist(repo_name, commit_oid) { + const full_repo_name = addRepoDirSuffix(repo_name); + const repo = await Repository.openBare(`${this.base_dir}/${full_repo_name}`); + + try { + await repo.getCommit(commit_oid); + return true; + } + catch { + return false; + } + } +}; \ No newline at end of file diff --git a/packages/server/src/api/util.js b/packages/server/src/api/util.js deleted file mode 100644 index aa31296..0000000 --- a/packages/server/src/api/util.js +++ /dev/null @@ -1,45 +0,0 @@ -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/util.ts b/packages/server/src/api/util.ts new file mode 100644 index 0000000..e4a7d17 --- /dev/null +++ b/packages/server/src/api/util.ts @@ -0,0 +1,79 @@ +import { Git, GitRequestInfo } from "./git"; +import { readdir } from "fs"; + +type VerificationResultErrorType = "REPO_NOT_FOUND" | "REPO_INVALID" | "COMMIT_NOT_FOUND" | "COMMIT_INVALID" | "ACCESS_DENIED"; + +const verification_error_types = { + REPO_NOT_FOUND: { code: 404, message: "Repository not found!" }, + REPO_INVALID: { code: 403, message: "Invalid repository!" }, + COMMIT_NOT_FOUND: { code: 404, message: "Commit not found!" }, + COMMIT_INVALID: { code: 403, message: "Invalid commit!" }, + ACCESS_DENIED: { code: 403, message: "Access denied!" } +}; + +export class VerificationResult { + constructor(success: boolean, error_type?: VerificationResultErrorType) { + this.success = success; + + if(error_type) { + this.message = verification_error_types[error_type].message; + this.code = verification_error_types[error_type].code; + } + } + + success: boolean; + code: number | null = null; + message: string | null = null; +} + +export function verifyRepoName(base_dir: string, repo_name: string) { + return new Promise(resolve => { + console.log(repo_name); + const is_valid_repo_name = (/^[a-zA-Z0-9.\-_]+$/u).test(repo_name); + if(!is_valid_repo_name) { + resolve(new VerificationResult(false, "REPO_INVALID")); + return; + } + + readdir(base_dir, (err: Error, dir_content: string[]) => { + if(err) { + resolve(new VerificationResult(false, "REPO_NOT_FOUND")); + return; + } + + const dir_content_repos = dir_content.filter(repo => repo.endsWith(".git")); + if(!dir_content_repos.includes(repo_name + ".git")) { + resolve(new VerificationResult(false, "REPO_NOT_FOUND")); + return; + } + + resolve(new VerificationResult(true)); + }); + }); +} + +export async function verifyCommitID(git: Git, repo: string, commit_id: string) { + if(!(/^[a-fA-F0-9]+$/u).test(commit_id)) { + return new VerificationResult(false, "COMMIT_INVALID"); + } + + const commit_exists = await git.doesCommitExist(repo, commit_id); + + if(!commit_exists) { + return new VerificationResult(false, "COMMIT_NOT_FOUND"); + } + + return new VerificationResult(true); +} + +export function verifyGitRequest(request_info: GitRequestInfo): VerificationResult { + if((/\.\/|\.\./u).test(request_info.parsed_url.pathname)) { + return new VerificationResult(false, "REPO_NOT_FOUND"); + } + + if(request_info.service !== "git-upload-pack") { + return new VerificationResult(false, "ACCESS_DENIED"); + } + + return new VerificationResult(true); +} \ No newline at end of file diff --git a/packages/server/src/api/v1.js b/packages/server/src/api/v1.js deleted file mode 100644 index 25a8019..0000000 --- a/packages/server/src/api/v1.js +++ /dev/null @@ -1,136 +0,0 @@ -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/api/v1.ts b/packages/server/src/api/v1.ts new file mode 100644 index 0000000..617e5f1 --- /dev/null +++ b/packages/server/src/api/v1.ts @@ -0,0 +1,123 @@ +import { verifyCommitID, verifyRepoName } from "./util"; +import { FastifyInstance } from "fastify"; +import { Git } from "./git"; +/* eslint-disable max-lines-per-function */ + +export default function(fastify: FastifyInstance, opts, done) { + const git = new Git(opts.config.settings.base_dir); + + fastify.route({ + method: "GET", + url: "/info", + handler: (req, reply) => { + reply.send({ data: opts.config.settings }); + } + }); + fastify.route({ + method: "GET", + url: "/repos", + handler: async(req, reply) => { + let repos = await git.getRepos(); + + if(repos["error"]) { + reply.code(500).send({ error: "Internal server error!" }); + return; + } + + reply.send({ data: repos }); + } + }); + + fastify.route({ + method: "GET", + url: "/repos/:repo", + handler: async(req, reply) => { + const params: any = req.params; + const repo_verification = await verifyRepoName(opts.config.settings.base_dir, params.repo); + if(repo_verification.success === false) { + reply.code(repo_verification.code).send(repo_verification.message); + } + + const desc = await git.getRepoFile(params.repo, "description"); + + reply.send({ data: { name: params.repo, description: desc } }); + } + }); + + fastify.register((fastify_repo, opts_repo, done_repo) => { + fastify_repo.addHook("onRequest", async(req, reply) => { + const params: any = req.params; + const repo_verification = await verifyRepoName(opts.config.settings.base_dir, params.repo); + if(repo_verification.success === false) { + reply.code(repo_verification.code).send({ error: repo_verification.message }); + } + }); + + fastify_repo.route({ + method: "GET", + url: "/log", + handler: async(req, reply) => { + const log = await git.getLog((req.params).repo); + + 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; + default: + reply.code(500).send({ error: "Internal server error!" }); + return; + } + } + reply.send({ data: log }); + } + }); + + fastify_repo.route({ + method: "GET", + url: "/log/:commit", + handler: async(req, reply) => { + const params: any = req.params; + const commit_verification = await verifyCommitID(git, params.repo, params.commit); + if(commit_verification.success === false) { + reply.code(commit_verification.code).send(commit_verification.message); + } + + const commit = await git.getCommit(params.repo, params.commit); + + reply.send({ data: commit }); + } + }); + + fastify_repo.route({ + method: "GET", + url: "/tree", + handler: async(req, reply) => { + const params: any = req.params; + const query: any = req.query; + + const tree_path = (query.length !== 0 && query.path) ? query.path : null; + + const tree = await git.getTree(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 deleted file mode 100644 index a106f7d..0000000 --- a/packages/server/src/app.js +++ /dev/null @@ -1,98 +0,0 @@ -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 diff --git a/packages/server/src/app.ts b/packages/server/src/app.ts new file mode 100644 index 0000000..810ca65 --- /dev/null +++ b/packages/server/src/app.ts @@ -0,0 +1,127 @@ +import { readFileSync, readdirSync } from "fs"; +import { Git } from "./api/git"; +import api from "./api/v1"; +import { exit } from "process"; +import { fastify as fastifyFactory } from "fastify"; +import { join } from "path"; +import { load } from "js-yaml"; +import { verifyRepoName } from "./api/util"; + +const settings = load(readFileSync(join(__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 { + readdirSync(settings.base_dir); +} +catch { + console.error(`Error: Tried opening the base directory. No such directory: ${settings.base_dir}`); + exit(1); +} + +const dist_dir = join(__dirname, "/../../client/dist"); + +if(settings.production) { + try { + readdirSync(dist_dir); + } + catch { + console.error("Error: Tried opening the dist directory but it doesn't exist.\nDid you accidentally turn on the production setting?"); + exit(1); + } +} + +const fastify = fastifyFactory(); +const git = new Git(settings.base_dir); + +fastify.setNotFoundHandler({}, function(req, reply) { + reply.code(404).send("Page not found!"); +}); + +if(settings.production) { + fastify.register(require("fastify-static"), { root: dist_dir }); + + fastify.route({ + method: "GET", + url: "/", + handler: (req, reply: any) => 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 } }); + +interface Query { + [key: string]: string +} + +fastify.route({ + method: "GET", + url: "/:repo([a-zA-Z0-9\\.\\-_]+)/info/refs", + handler: async(req, reply) => { + reply.header("Content-Type", "application/x-git-upload-pack-advertisement"); + + const repo_verification = await verifyRepoName(settings.base_dir, (req).params.repo); + if(repo_verification.success === false) { + reply.code(repo_verification.code).send(repo_verification.message); + } + + const query: Query = req.query; + if(!query.service) { + reply.code(403).send("Missing service query parameter\n"); + return; + } + else if(query.service !== "git-upload-pack") { + reply.code(403).send("Access denied!\n"); + return; + } + else if(Object.keys(query).length !== 1) { + reply.code(403).send("Too many query parameters!\n"); + return; + } + + git.connectToGitHTTPBackend(req, reply); + } +}); + +fastify.route({ + method: "POST", + url: "/:repo([a-zA-Z0-9\\.\\-_]+)/git-upload-pack", + handler: async(req, reply) => { + const repo_verification = await verifyRepoName(settings.base_dir, (req).params.repo); + if(repo_verification.success === false) { + reply.header("Content-Type", "application/x-git-upload-pack-result"); + reply.code(repo_verification.code).send(repo_verification.message); + } + + git.connectToGitHTTPBackend(req, reply); + } +}); + +fastify.listen(settings.port, settings.host, (err: Error, addr: string) => { + if(err) { + console.error(err); + exit(1); + } + + console.log(`App is running on ${addr}`); +}); \ No newline at end of file -- cgit v1.2.3-18-g5258