From 73e46f926f298ebe4808ad5950631a22822e1e62 Mon Sep 17 00:00:00 2001 From: Thomas Durieux <5577568+tdurieux@users.noreply.github.com> Date: Sun, 22 Jan 2023 12:54:14 +0100 Subject: [PATCH] feat: add support for pull requests (#156) --- public/css/style.css | 26 +- public/partials/anonymizePullRequest.htm | 506 +++++++++++++++ public/partials/dashboard.htm | 9 +- public/partials/header.htm | 17 + public/partials/pr-dashboard.htm | 316 ++++++++++ public/partials/pullRequest.htm | 118 ++++ public/script/app.js | 588 +++++++++++++++++- public/script/utils.js | 18 +- src/PullRequest.ts | 311 +++++++++ src/User.ts | 27 + src/anonymize-utils.ts | 19 +- .../anonymizedPullRequests.model.ts | 14 + .../anonymizedPullRequests.schema.ts | 66 ++ .../anonymizedPullRequests.types.ts | 61 ++ src/database/database.ts | 21 +- src/routes/index.ts | 4 + src/routes/pullRequest-private.ts | 245 ++++++++ src/routes/pullRequest-public.ts | 84 +++ src/routes/repository-public.ts | 5 +- src/routes/route-utils.ts | 31 +- src/routes/user.ts | 15 + src/routes/webview.ts | 4 +- src/server.ts | 2 + 23 files changed, 2479 insertions(+), 28 deletions(-) create mode 100644 public/partials/anonymizePullRequest.htm create mode 100644 public/partials/pr-dashboard.htm create mode 100644 public/partials/pullRequest.htm create mode 100644 src/PullRequest.ts create mode 100644 src/database/anonymizedPullRequests/anonymizedPullRequests.model.ts create mode 100644 src/database/anonymizedPullRequests/anonymizedPullRequests.schema.ts create mode 100644 src/database/anonymizedPullRequests/anonymizedPullRequests.types.ts create mode 100644 src/routes/pullRequest-private.ts create mode 100644 src/routes/pullRequest-public.ts diff --git a/public/css/style.css b/public/css/style.css index 06a54ed..094f65f 100644 --- a/public/css/style.css +++ b/public/css/style.css @@ -563,7 +563,27 @@ loc .lang { } .highlighted-line { - position:absolute; - background:rgba(100,200,100,0.5); - z-index:20 + position: absolute; + background: rgba(100, 200, 100, 0.5); + z-index: 20 +} + +pre, +code { + font-family: "Fira Code", "Courier New", Courier, monospace; + line-height: 1.1; +} + +.diff-lines, +.diff-file, +.diff-index { + background: rgba(172, 172, 172, 0.5); +} + +.diff-add { + background: rgba(100, 200, 100, 0.5); +} + +.diff-remove { + background: rgba(200, 100, 100, 0.5); } \ No newline at end of file diff --git a/public/partials/anonymizePullRequest.htm b/public/partials/anonymizePullRequest.htm new file mode 100644 index 0000000..dcd7987 --- /dev/null +++ b/public/partials/anonymizePullRequest.htm @@ -0,0 +1,506 @@ +
+
+
+
+
+
Anonymize a pull request
+
+ Fill the information to anonymize! It will only take 5min. +
+

Source

+ +
+ + +
+ {{pullRequestUrl}} is not accessible. Some organizations are + restricting the access to the repositories. +
+
+ {{pullRequestUrl}} does not exist or is not accessible +
+
+ {{pullRequestUrl}} is already anonymized +
+
+
+
+
+ + + Automatically update the anonymized pull request with the + latest updates. The pull request is updated once per day + maximum. +
+
+

Conference ID

+ +
+ + + {{conference_data.name}} + will expire on {{conference_data.endDate | date}}. + +
+ The conference is not activated. +
+ + Use the Conference ID that your conference provided you. This + will update automatically the anonymization options based on the + conference preferences. + +
+

Anonymization Options

+ +
+ + + Id used in the url: + https://anonymous.4open.science/r/{{pullRequestId}} +
+ Repository id can only contain letters and numbers +
+
+ {{pullRequestId}} is already used +
+
+ +
+ + + One term per line. Each term will be replaced by XXX. +
+ Terms are in an invalid format +
+
+ +
+ + + Define the expiration strategy for the anonymized + repository. +
+
+ + + After {{options.expirationDate | date}}, the repository will be + removed and the visitor will not be able to see the content of + the repository. + After {{options.expirationDate | date}}, the visitors of the + anonymized repository will be redirected to + {{pullRequestUrl}}. +
+ +
+
+
+

+ +

+
+ +
+
+
+
+ + + Keep or remove all the links. +
+
+ + + Images are not anonymized +
+
+ + + Display the date of the Pull Request and the date of + the comments. +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+
+
+
+
+ + + +
+
+
+
+
+

+ {{anonymize(details.pullRequest.title)}} + + {{details.pullRequest.merged?"merged":details.pullRequest.state | + title}} + +

+ +
+ Pull Request on {{details.pullRequest.baseRepositoryFullName}} +
+ +
+ +
+
+
+
+
+
+
+
    +
  • +
    +
    + @{{anonymize(comment.author)}} +
    + +
    +

    + +

    +
  • +
+
+
+
+
+
diff --git a/public/partials/dashboard.htm b/public/partials/dashboard.htm index 244966d..83b1e8c 100644 --- a/public/partials/dashboard.htm +++ b/public/partials/dashboard.htm @@ -1,9 +1,9 @@
-
+
-
+
- Anonymize + Anonymize Repo + + + Anonymize PR + +
diff --git a/public/partials/pullRequest.htm b/public/partials/pullRequest.htm new file mode 100644 index 0000000..aff5d01 --- /dev/null +++ b/public/partials/pullRequest.htm @@ -0,0 +1,118 @@ +
+
+
+
+
+ Last Update: {{details.anonymizeDate|date}} +
+
+
+
+

+ {{details.title}} + + {{details.merged?"merged":details.state | title}} + +

+ +
+ Pull Request on {{details.baseRepositoryFullName}} +
+ +
+ +
+
+
+
+
+
+
+
    +
  • +
    +
    + @{{comment.author}} +
    + +
    +

    + +

    +
  • +
+
+
+
+
+
+
diff --git a/public/script/app.js b/public/script/app.js index ba4367f..48e7c6a 100644 --- a/public/script/app.js +++ b/public/script/app.js @@ -34,11 +34,21 @@ angular controller: "dashboardController", title: "Dashboard - Anonymous GitHub", }) + .when("/pr-dashboard", { + templateUrl: "/partials/pr-dashboard.htm", + controller: "prDashboardController", + title: "Pull Request Dashboard - Anonymous GitHub", + }) .when("/anonymize/:repoId?", { templateUrl: "/partials/anonymize.htm", controller: "anonymizeController", title: "Anonymize - Anonymous GitHub", }) + .when("/pull-request-anonymize/:pullRequestId?", { + templateUrl: "/partials/anonymizePullRequest.htm", + controller: "anonymizePullRequestController", + title: "Anonymize - Anonymous GitHub", + }) .when("/status/:repoId", { templateUrl: "/partials/status.htm", controller: "statusController", @@ -79,6 +89,12 @@ angular controller: "claimController", title: "Claim repository - Anonymous GitHub", }) + .when("/pr/:pullRequestId", { + templateUrl: "/partials/pullRequest.htm", + controller: "pullRequestController", + title: "Anonymized Pull Request - Anonymous GitHub", + reloadOnUrl: false, + }) .when("/r/:repoId/:path*?", { templateUrl: "/partials/explorer.htm", controller: "exploreController", @@ -205,6 +221,53 @@ angular return capitalized.join(" "); }; }) + .filter("diff", function ($sce) { + return function (str) { + if (!str) return str; + const lines = str.split("\n"); + const o = []; + for (let i = 1; i < lines.length; i++) { + if (lines[i].startsWith("+++")) { + o.push(`${lines[i]}`); + } else if (lines[i].startsWith("---")) { + o.push(`${lines[i]}`); + } else if (lines[i].startsWith("@@")) { + o.push(`${lines[i]}`); + } else if (lines[i].startsWith("index")) { + o.push(`${lines[i]}`); + } else if (lines[i].startsWith("+")) { + o.push(`${lines[i]}`); + } else if (lines[i].startsWith("-")) { + o.push(`${lines[i]}`); + } else { + o.push(`${lines[i]}`); + } + } + return $sce.trustAsHtml(o.join("\n")); + }; + }) + .directive("markdown", [ + "$location", + function ($location) { + return { + restrict: "E", + scope: { + terms: "=", + options: "=", + content: "=", + }, + link: function (scope, elem, attrs) { + function update() { + elem.html(marked(scope.content, { baseUrl: $location.url() })); + } + scope.$watch(attrs.terms, update); + scope.$watch("terms", update); + scope.$watch("options", update); + scope.$watch("content", update); + }, + }; + }, + ]) .directive("tree", [ function () { return { @@ -748,6 +811,123 @@ angular }; }, ]) + .controller("prDashboardController", [ + "$scope", + "$http", + "$location", + function ($scope, $http, $location) { + $scope.$on("$routeChangeStart", function () { + // remove tooltip + $('[data-toggle="tooltip"]').tooltip("dispose"); + }); + $scope.$watch("user.status", () => { + if ($scope.user == null) { + $location.url("/"); + } + }); + if ($scope.user == null) { + $location.url("/"); + } + + setTimeout(() => { + $('[data-toggle="tooltip"]').tooltip(); + }, 250); + + $scope.pullRequests = []; + $scope.search = ""; + $scope.filters = { + status: { ready: true, expired: true, removed: false }, + }; + $scope.orderBy = "-anonymizeDate"; + + function getPullRequests() { + $http.get("/api/user/anonymized_pull_requests").then( + (res) => { + $scope.pullRequests = res.data; + for (const pr of $scope.pullRequests) { + if (!pr.pageView) { + pr.pageView = 0; + } + if (!pr.lastView) { + pr.lastView = ""; + } + pr.options.terms = pr.options.terms.filter((f) => f); + } + }, + (err) => { + console.error(err); + } + ); + } + getPullRequests(); + + $scope.removePullRequest = (pr) => { + if ( + confirm( + `Are you sure that you want to remove the pull request ${pr.pullRequestId}?` + ) + ) { + const toast = { + title: `Removing ${pr.pullRequestId}...`, + date: new Date(), + body: `The pull request ${pr.pullRequestId} is going to be removed.`, + }; + $scope.toasts.push(toast); + $http.delete(`/api/pr/${pr.pullRequestId}`).then( + () => { + toast.title = `${pr.pullRequestId} is removed.`; + toast.body = `The pull request ${pr.pullRequestId} is removed.`; + + getPullRequests(); + }, + (error) => { + toast.title = `Error during the removal of ${pr.pullRequestId}.`; + toast.body = error.body; + + getPullRequests(); + } + ); + } + }; + + $scope.updatePullRequest = (pr) => { + const toast = { + title: `Refreshing ${pr.pullRequestId}...`, + date: new Date(), + body: `The pull request ${pr.pullRequestId} is going to be refreshed.`, + }; + $scope.toasts.push(toast); + + $http.post(`/api/pr/${pr.pullRequestId}/refresh`).then( + () => { + toast.title = `${pr.pullRequestId} is refreshed.`; + toast.body = `The pull request ${pr.pullRequestId} is refreshed.`; + getPullRequests(); + }, + (error) => { + toast.title = `Error during the refresh of ${pr.pullRequestId}.`; + toast.body = error.body; + + getPullRequests(); + } + ); + }; + + $scope.pullRequestFilter = (pr) => { + if ($scope.filters.status[pr.status] == false) return false; + + if ($scope.search.trim().length == 0) return true; + + if ((pr.source.pullRequestId + "").indexOf($scope.search) > -1) + return true; + if (pr.source.repositoryFullName.indexOf($scope.search) > -1) + return true; + if (pr.pullRequestId.indexOf($scope.search) > -1) return true; + + return false; + }; + }, + ]) .controller("statusController", [ "$scope", "$http", @@ -778,7 +958,10 @@ angular } else if ($scope.repo.status == "anonymizing") { $scope.progress = 75; } - if ($scope.repo.status != "ready" && $scope.repo.status != "error") { + if ( + $scope.repo.status != "ready" && + $scope.repo.status != "error" + ) { setTimeout($scope.getStatus, 2000); } }, @@ -1267,11 +1450,25 @@ angular ts: "typescript", }; const textFiles = ["license", "txt"]; - const imageFiles = ["png", "jpg", "jpeg", "gif", "svg", "ico", "bmp", "tiff", "tif", "webp", "avif", "heif", "heic"]; + const imageFiles = [ + "png", + "jpg", + "jpeg", + "gif", + "svg", + "ico", + "bmp", + "tiff", + "tif", + "webp", + "avif", + "heif", + "heic", + ]; $scope.$on("$routeUpdate", function (event, current) { if (($routeParams.path || "") == $scope.filePath) { - return + return; } $scope.filePath = $routeParams.path || ""; $scope.paths = $scope.filePath.split("/"); @@ -1409,7 +1606,9 @@ angular if ($scope.type == "md") { const md = contentAbs2Relative(res.data); - $scope.content = $sce.trustAsHtml(marked(md, { baseUrl: $location.url() })); + $scope.content = $sce.trustAsHtml( + marked(md, { baseUrl: $location.url() }) + ); $scope.type = "html"; } if ($scope.type == "org") { @@ -1481,7 +1680,7 @@ angular if (window.location.hash && window.location.hash.match(/^#L\d+/)) { let from = 0; let to = 0; - if (window.location.hash.indexOf('-') > -1) { + if (window.location.hash.indexOf("-") > -1) { const match = window.location.hash.match(/^#L(\d+)-L(\d+)/); from = parseInt(match[1]) - 1; to = parseInt(match[2]) - 1; @@ -1489,9 +1688,13 @@ angular from = parseInt(window.location.hash.substring(2)) - 1; to = from; } - - const Range = ace.require('ace/range').Range; - _editor.session.addMarker(new Range(from, 0, to, 1), "highlighted-line", "fullLine"); + + const Range = ace.require("ace/range").Range; + _editor.session.addMarker( + new Range(from, 0, to, 1), + "highlighted-line", + "fullLine" + ); setTimeout(() => { _editor.scrollToLine(from, true, true, function () {}); }, 100); @@ -1559,6 +1762,375 @@ angular init(); }, ]) + .controller("anonymizePullRequestController", [ + "$scope", + "$http", + "$sce", + "$routeParams", + "$location", + "$translate", + function ($scope, $http, $sce, $routeParams, $location, $translate) { + $scope.pullRequestUrl = ""; + $scope.pullRequestId = ""; + $scope.terms = ""; + $scope.defaultTerms = ""; + $scope.options = { + expirationMode: "remove", + expirationDate: new Date(), + update: false, + image: true, + link: true, + body: true, + title: true, + origin: false, + diff: true, + comments: true, + username: true, + date: true, + }; + $scope.options.expirationDate.setMonth( + $scope.options.expirationDate.getMonth() + 4 + ); + $scope.isUpdate = false; + + function getDefault(cb) { + $http.get("/api/user/default").then((res) => { + const data = res.data; + if (data.terms) { + $scope.defaultTerms = data.terms.join("\n"); + } + $scope.options = Object.assign({}, $scope.options, data.options); + $scope.options.expirationDate = new Date( + $scope.options.expirationDate + ); + $scope.options.expirationDate.setDate( + $scope.options.expirationDate.getDate() + 90 + ); + if (cb) cb(); + }); + } + + getDefault(() => { + if ($routeParams.pullRequestId && $routeParams.pullRequestId != "") { + $scope.isUpdate = true; + $scope.pullRequestId = $routeParams.pullRequestId; + $http.get("/api/pr/" + $scope.pullRequestId).then( + async (res) => { + $scope.pullRequestUrl = + "https://github.com/" + + res.data.source.repositoryFullName + + "/pull/" + + res.data.source.pullRequestId; + + $scope.terms = res.data.options.terms.filter((f) => f).join("\n"); + $scope.source = res.data.source; + $scope.options = res.data.options; + $scope.conference = res.data.conference; + if (res.data.options.expirationDate) { + $scope.options.expirationDate = new Date( + res.data.options.expirationDate + ); + } else { + $scope.options.expirationDate = new Date(); + $scope.options.expirationDate.setDate( + $scope.options.expirationDate.getDate() + 90 + ); + } + + $scope.details = ( + await $http.get( + `/api/pr/${res.data.source.repositoryFullName}/${res.data.source.pullRequestId}` + ) + ).data; + $scope.$apply(); + }, + (err) => { + $location.url("/404"); + } + ); + $scope.$watch("anonymize", () => { + $scope.anonymizeForm.pullRequestId.$$element[0].disabled = true; + $scope.anonymizeForm.pullRequestUrl.$$element[0].disabled = true; + }); + } + }); + + $scope.pullRequestSelected = async () => { + $scope.terms = $scope.defaultTerms; + $scope.pullRequestId = ""; + $scope.source = {}; + + try { + const o = parseGithubUrl($scope.pullRequestUrl); + if (!o.pullRequestId) { + $scope.anonymizeForm.pullRequestUrl.$setValidity("github", false); + return; + } + $scope.anonymizeForm.pullRequestUrl.$setValidity("github", true); + } catch (error) { + $scope.anonymizeForm.pullRequestUrl.$setValidity("github", false); + return; + } + try { + await getDetails(); + } catch (error) {} + $scope.$apply(); + $('[data-toggle="tooltip"]').tooltip(); + }; + $('[data-toggle="tooltip"]').tooltip(); + + $scope.$watch("options.update", (v) => {}); + + async function getDetails() { + const o = parseGithubUrl($scope.pullRequestUrl); + try { + resetValidity(); + const res = await $http.get( + `/api/pr/${o.owner}/${o.repo}/${o.pullRequestId}` + ); + $scope.details = res.data; + if ($scope.options.origin) { + $scope.pullRequestId = o.repo + "-" + generateRandomId(4); + } else { + $scope.pullRequestId = generateRandomId(4); + } + } catch (error) { + if (error.data) { + $translate("ERRORS." + error.data.error).then((translation) => { + $scope.error = translation; + }, console.error); + displayErrorMessage(error.data.error); + } + $scope.anonymizeForm.pullRequestUrl.$setValidity("missing", false); + throw error; + } + } + + function getConference() { + if (!$scope.conference) return; + $http.get("/api/conferences/" + $scope.conference).then( + (res) => { + $scope.conference_data = res.data; + $scope.conference_data.startDate = new Date( + $scope.conference_data.startDate + ); + $scope.conference_data.endDate = new Date( + $scope.conference_data.endDate + ); + + $scope.options.expirationDate = new Date( + $scope.conference_data.endDate + ); + $scope.options.expirationMode = "remove"; + + $scope.options.update = $scope.conference_data.options.update; + $scope.options.image = $scope.conference_data.options.image; + $scope.options.pdf = $scope.conference_data.options.pdf; + $scope.options.notebook = $scope.conference_data.options.notebook; + $scope.options.link = $scope.conference_data.options.link; + }, + (err) => { + $scope.conference_data = null; + } + ); + } + + $scope.anonymize = function (content) { + const urlRegex = + /?/g; + + if (!$scope.options.image) { + // remove images + content = content.replace( + /!\[[^\]]*\]\((?.*?)(?=\"|\))(?\".*\")?\)/g, + "" + ); + } + if (!$scope.options.link) { + content = content.replace( + urlRegex, + $scope.site_options.ANONYMIZATION_MASK + ); + } + const terms = $scope.terms.split("\n"); + for (let i = 0; i < terms.length; i++) { + const term = terms[i]; + if (term.trim() == "") { + continue; + } + // remove whole url if it contains the term + content = content.replace(urlRegex, (match) => { + if (new RegExp(`\\b${term}\\b`, "gi").test(match)) + return $scope.site_options.ANONYMIZATION_MASK + "-" + (i + 1); + return match; + }); + + // remove the term in the text + content = content.replace( + new RegExp(`\\b${term}\\b`, "gi"), + $scope.site_options.ANONYMIZATION_MASK + "-" + (i + 1) + ); + } + return content; + }; + + function resetValidity() { + $scope.anonymizeForm.pullRequestId.$setValidity("used", true); + $scope.anonymizeForm.pullRequestId.$setValidity("format", true); + $scope.anonymizeForm.pullRequestUrl.$setValidity("used", true); + $scope.anonymizeForm.pullRequestUrl.$setValidity("missing", true); + $scope.anonymizeForm.pullRequestUrl.$setValidity("access", true); + $scope.anonymizeForm.conference.$setValidity("activated", true); + $scope.anonymizeForm.terms.$setValidity("format", true); + $scope.anonymizeForm.terms.$setValidity("format", true); + } + + function displayErrorMessage(message) { + switch (message) { + case "repoId_already_used": + $scope.anonymizeForm.repoId.$setValidity("used", false); + break; + case "invalid_repoId": + $scope.anonymizeForm.repoId.$setValidity("format", false); + break; + case "options_not_provided": + $scope.anonymizeForm.repoId.$setValidity("format", false); + break; + case "repo_already_anonymized": + $scope.anonymizeForm.repoUrl.$setValidity("used", false); + break; + case "invalid_terms_format": + $scope.anonymizeForm.terms.$setValidity("format", false); + break; + case "invalid_terms_format": + $scope.anonymizeForm.terms.$setValidity("format", false); + break; + case "repo_not_found": + $scope.anonymizeForm.repoUrl.$setValidity("missing", false); + break; + case "repo_not_accessible": + $scope.anonymizeForm.repoUrl.$setValidity("access", false); + break; + case "conf_not_activated": + $scope.anonymizeForm.conference.$setValidity("activated", false); + break; + default: + $scope.anonymizeForm.$setValidity("error", false); + break; + } + } + + function getPullRequest() { + const o = parseGithubUrl($scope.pullRequestUrl); + return { + pullRequestId: $scope.pullRequestId, + terms: $scope.terms + .trim() + .split("\n") + .filter((f) => f), + source: { + repositoryFullName: `${o.owner}/${o.repo}`, + pullRequestId: o.pullRequestId, + }, + options: $scope.options, + conference: $scope.conference, + }; + } + + async function sendPullRequest(url) { + resetValidity(); + try { + const newPR = getPullRequest(); + await $http.post(url, newPR, { + headers: { "Content-Type": "application/json" }, + }); + window.location.href = "/pr/" + $scope.pullRequestId; + } catch (error) { + if (error.data) { + $translate("ERRORS." + error.data.error).then((translation) => { + $scope.error = translation; + }, console.error); + displayErrorMessage(error.data.error); + } else { + console.error(error); + } + } + } + + $scope.anonymizePullRequest = (event) => { + event.target.disabled = true; + sendPullRequest("/api/pr/").finally(() => { + event.target.disabled = false; + $scope.$apply(); + }); + }; + + $scope.updatePullRequest = async (event) => { + event.target.disabled = true; + sendPullRequest("/api/pr/" + $scope.pullRequestId).finally(() => { + event.target.disabled = false; + $scope.$apply(); + }); + }; + + $scope.$watch("conference", async (v) => { + getConference(); + }); + }, + ]) + .controller("pullRequestController", [ + "$scope", + "$http", + "$location", + "$routeParams", + "$sce", + function ($scope, $http, $location, $routeParams, $sce) { + async function getOption(callback) { + $http.get(`/api/pr/${$scope.pullRequestId}/options`).then( + (res) => { + $scope.options = res.data; + if ($scope.options.url) { + // the repository is expired with redirect option + window.location = $scope.options.url; + return; + } + if (callback) { + callback(res.data); + } + }, + (err) => { + $scope.type = "error"; + $scope.content = err.data.error; + } + ); + } + async function getPullRequest(callback) { + $http.get(`/api/pr/${$scope.pullRequestId}/content`).then( + (res) => { + $scope.details = res.data; + if (callback) { + callback(res.data); + } + }, + (err) => { + $scope.type = "error"; + $scope.content = err.data.error; + } + ); + } + + function init() { + $scope.pullRequestId = $routeParams.pullRequestId; + $scope.type = "loading"; + + getOption((_) => { + getPullRequest(); + }); + } + + init(); + }, + ]) .controller("conferencesController", [ "$scope", "$http", diff --git a/public/script/utils.js b/public/script/utils.js index b96d609..3fda749 100644 --- a/public/script/utils.js +++ b/public/script/utils.js @@ -8,7 +8,7 @@ function urlRel2abs(url) { return url; //Url is already absolute } var base_url = location.href.match(/^(.+)\/?(?:#.+)?$/)[0] + "/"; - + if (url.substring(0, 2) == "//") return location.protocol + url; else if (url.charAt(0) == "/") return location.protocol + "//" + location.host + url; @@ -28,7 +28,7 @@ function urlRel2abs(url) { .replace(/'/g, "%27") .replace(//g, "%3E"); - + return url; } @@ -93,11 +93,17 @@ function generateRandomId(length) { } function parseGithubUrl(url) { - var matches = url.replace(".git", "").match(/.*?github.com\/([\w-\._]+)\/([\w-\._]+)/); - if (matches && matches.length == 3) { + if (!url) throw "Invalid url"; + const matches = url + .replace(".git", "") + .match( + /.*?github.com\/(?[\w-\._]+)\/(?[\w-\._]+)(\/pull\/(?[0-9]+))?/ + ); + if (matches && matches.groups.owner && matches.groups.repo) { return { - owner: matches[1], - repo: matches[2], + owner: matches.groups.owner, + repo: matches.groups.repo, + pullRequestId: matches.groups.PR, }; } else { throw "Invalid url"; diff --git a/src/PullRequest.ts b/src/PullRequest.ts new file mode 100644 index 0000000..be379f8 --- /dev/null +++ b/src/PullRequest.ts @@ -0,0 +1,311 @@ +import { RepositoryStatus, Source, Tree, TreeElement, TreeFile } from "./types"; +import User from "./User"; +import { anonymizeContent, anonymizePath } from "./anonymize-utils"; +import UserModel from "./database/users/users.model"; +import Conference from "./Conference"; +import ConferenceModel from "./database/conference/conferences.model"; +import AnonymousError from "./AnonymousError"; +import { IAnonymizedPullRequestDocument } from "./database/anonymizedPullRequests/anonymizedPullRequests.types"; +import config from "../config"; +import { Octokit } from "@octokit/rest"; +import got from "got"; + +export default class PullRequest { + private _model: IAnonymizedPullRequestDocument; + owner: User; + + constructor(data: IAnonymizedPullRequestDocument) { + this._model = data; + this.owner = new User(new UserModel({ _id: data.owner })); + } + + getToken() { + if (this.owner && this.owner.accessToken) { + return this.owner.accessToken; + } + if (this._model.source.accessToken) { + try { + return this._model.source.accessToken; + } catch (error) { + console.debug("[ERROR] Token is invalid", this.pullRequestId); + } + } + return config.GITHUB_TOKEN; + } + + async download() { + console.debug("[INFO] Downloading pull request", this.pullRequestId); + const auth = this.getToken(); + const octokit = new Octokit({ auth }); + const [owner, repo] = this._model.source.repositoryFullName.split("/"); + const pull_number = this._model.source.pullRequestId; + const prInfo = await octokit.rest.pulls.get({ + owner, + repo, + pull_number, + }); + + prInfo.data.updated_at; + prInfo.data.draft; + prInfo.data.merged; + prInfo.data.merged_at; + prInfo.data.state; + prInfo.data.base.repo.full_name; + prInfo.data.head.repo.full_name; + const comments = await octokit.rest.issues.listComments({ + owner, + repo, + issue_number: pull_number, + per_page: 100, + }); + // const commits = await octokit.rest.pulls.listCommits({ + // owner, + // repo, + // pull_number, + // per_page: 100, + // }); + // const files = await octokit.rest.pulls.listFiles({ + // owner, + // repo, + // pull_number, + // per_page: 100, + // }); + const diff = await got(prInfo.data.diff_url); + this._model.pullRequest = { + diff: diff.body, + title: prInfo.data.title, + body: prInfo.data.body, + creationDate: new Date(prInfo.data.created_at), + updatedDate: new Date(prInfo.data.updated_at), + draft: prInfo.data.draft, + merged: prInfo.data.merged, + mergedDate: prInfo.data.merged_at + ? new Date(prInfo.data.merged_at) + : null, + state: prInfo.data.state, + baseRepositoryFullName: prInfo.data.base.repo.full_name, + headRepositoryFullName: prInfo.data.head.repo.full_name, + comments: comments.data.map((comment) => ({ + body: comment.body, + creationDate: new Date(comment.created_at), + updatedDate: new Date(comment.updated_at), + author: comment.user.login, + })), + }; + } + + /** + * Check the status of the pullRequest + */ + check() { + if ( + this._model.options.expirationMode !== "never" && + this.status == "ready" + ) { + if (this._model.options.expirationDate <= new Date()) { + this.expire(); + } + } + if ( + this.status == "expired" || + this.status == "expiring" || + this.status == "removing" || + this.status == "removed" + ) { + throw new AnonymousError("pullRequest_expired", { + object: this, + httpStatus: 410, + }); + } + const fiveMinuteAgo = new Date(); + fiveMinuteAgo.setMinutes(fiveMinuteAgo.getMinutes() - 5); + + if ( + this.status == "preparing" || + (this.status == "download" && this._model.statusDate > fiveMinuteAgo) + ) { + throw new AnonymousError("pullRequest_not_ready", { + object: this, + }); + } + } + + /** + * Update the pullRequest if a new commit exists + * + * @returns void + */ + async updateIfNeeded(opt?: { force: boolean }): Promise { + const yesterday = new Date(); + yesterday.setDate(yesterday.getDate() - 1); + if ( + opt?.force || + (this._model.options.update && this._model.lastView < yesterday) + ) { + await this.download(); + this._model.lastView = new Date(); + await this._model.save(); + } + } + /** + * Download the require state for the pullRequest to work + * + * @returns void + */ + async anonymize() { + if (this.status == "ready") return; + await this.updateStatus("preparing"); + await this.updateIfNeeded({ force: true }); + return this.updateStatus("ready"); + } + + /** + * Update the last view and view count + */ + async countView() { + this._model.lastView = new Date(); + this._model.pageView = (this._model.pageView || 0) + 1; + return this._model.save(); + } + + /** + * Update the status of the pullRequest + * @param status the new status + * @param errorMessage a potential error message to display + */ + async updateStatus(status: RepositoryStatus, statusMessage?: string) { + this._model.status = status; + this._model.statusDate = new Date(); + this._model.statusMessage = statusMessage; + return this._model.save(); + } + + /** + * Expire the pullRequest + */ + async expire() { + await this.updateStatus("expiring"); + await this.resetSate(); + await this.updateStatus("expired"); + } + + /** + * Remove the pullRequest + */ + async remove() { + await this.updateStatus("removing"); + await this.resetSate(); + await this.updateStatus("removed"); + } + + /** + * Reset/delete the state of the pullRequest + */ + async resetSate(status?: RepositoryStatus, statusMessage?: string) { + if (status) this._model.status = status; + if (statusMessage) this._model.statusMessage = statusMessage; + // remove cache + this._model.pullRequest = null; + return Promise.all([this._model.save()]); + } + + /** + * Returns the conference of the pullRequest + * + * @returns conference of the pullRequest + */ + async conference(): Promise { + if (!this._model.conference) { + return null; + } + const conference = await ConferenceModel.findOne({ + conferenceID: this._model.conference, + }); + if (conference) return new Conference(conference); + return null; + } + + /***** Getters ********/ + + get pullRequestId() { + return this._model.pullRequestId; + } + + get options() { + return this._model.options; + } + + get source() { + return this._model.source; + } + + get model() { + return this._model; + } + + get status() { + return this._model.status; + } + + content() { + const output: any = { + anonymizeDate: this._model.anonymizeDate, + merged: this._model.pullRequest.merged, + mergedDate: this._model.pullRequest.mergedDate, + state: this._model.pullRequest.state, + draft: this._model.pullRequest.draft, + }; + if (this.options.title) { + output.title = anonymizeContent(this._model.pullRequest.title, this); + } + if (this.options.body) { + output.body = anonymizeContent(this._model.pullRequest.body, this); + } + if (this.options.comments) { + output.comments = this._model.pullRequest.comments.map((comment) => { + const o: any = {}; + if (this.options.body) o.body = anonymizeContent(comment.body, this); + if (this.options.username) + o.author = anonymizeContent(comment.author, this); + if (this.options.date) { + o.updatedDate = comment.updatedDate; + o.creationDate = comment.creationDate; + } + return o; + }); + } + if (this.options.diff) { + output.diff = anonymizeContent(this._model.pullRequest.diff, this); + } + if (this.options.origin) { + output.baseRepositoryFullName = + this._model.pullRequest.baseRepositoryFullName; + } + if (this.options.date) { + output.updatedDate = this.model.pullRequest.updatedDate; + output.creationDate = this.model.pullRequest.creationDate; + } + return output; + } + + toJSON() { + return { + pullRequestId: this._model.pullRequestId, + options: this._model.options, + conference: this._model.conference, + anonymizeDate: this._model.anonymizeDate, + status: this._model.status, + state: this.model.pullRequest.state, + merged: this.model.pullRequest.merged, + mergedDate: this.model.pullRequest.mergedDate, + statusMessage: this._model.statusMessage, + source: { + pullRequestId: this._model.source.pullRequestId, + repositoryFullName: this._model.source.repositoryFullName, + }, + pullRequest: this._model.pullRequest, + lastView: this._model.lastView, + pageView: this._model.pageView, + }; + } +} diff --git a/src/User.ts b/src/User.ts index 147a7d6..26cd1a6 100644 --- a/src/User.ts +++ b/src/User.ts @@ -4,6 +4,8 @@ import RepositoryModel from "./database/repositories/repositories.model"; import { IUserDocument } from "./database/users/users.types"; import Repository from "./Repository"; import { GitHubRepository } from "./source/GitHubRepository"; +import PullRequest from "./PullRequest"; +import AnonymizedPullRequestModel from "./database/anonymizedPullRequests/anonymizedPullRequests.model"; /** * Model for a user @@ -136,6 +138,31 @@ export default class User { await Promise.all(promises); return repositories; } + /** + * Get the lost of anonymized repositories + * @returns the list of anonymized repositories + */ + async getPullRequests() { + const pullRequests = ( + await AnonymizedPullRequestModel.find({ + owner: this.id, + }).exec() + ).map((d) => new PullRequest(d)); + const promises = []; + for (let repo of pullRequests) { + if ( + repo.status == "ready" && + repo.options.expirationMode != "never" && + repo.options.expirationDate != null && + repo.options.expirationDate < new Date() + ) { + // expire the repository + promises.push(repo.expire()); + } + } + await Promise.all(promises); + return pullRequests; + } get model() { return this._model; diff --git a/src/anonymize-utils.ts b/src/anonymize-utils.ts index 46d58fb..fc70e5d 100644 --- a/src/anonymize-utils.ts +++ b/src/anonymize-utils.ts @@ -72,7 +72,24 @@ export function anonymizeStream(filename: string, repository: Repository) { return ts; } -export function anonymizeContent(content: string, repository: Repository) { +interface Anonymizationptions { + repoId?: string; + source?: {}; + options: { + terms: string[]; + image: boolean; + link: boolean; + pageSource?: { + branch: string; + path: string; + }; + }; +} + +export function anonymizeContent( + content: string, + repository: Anonymizationptions +) { if (repository.options?.image === false) { // remove image in markdown content = content.replace( diff --git a/src/database/anonymizedPullRequests/anonymizedPullRequests.model.ts b/src/database/anonymizedPullRequests/anonymizedPullRequests.model.ts new file mode 100644 index 0000000..9c1e2f5 --- /dev/null +++ b/src/database/anonymizedPullRequests/anonymizedPullRequests.model.ts @@ -0,0 +1,14 @@ +import { model } from "mongoose"; + +import AnonymizedPullRequestSchema from "./anonymizedPullRequests.schema"; +import { + IAnonymizedPullRequestDocument, + IAnonymizedPullRequestModel, +} from "./anonymizedPullRequests.types"; + +const AnonymizedPullRequestModel = model( + "AnonymizedPullRequest", + AnonymizedPullRequestSchema +) as IAnonymizedPullRequestModel; + +export default AnonymizedPullRequestModel; diff --git a/src/database/anonymizedPullRequests/anonymizedPullRequests.schema.ts b/src/database/anonymizedPullRequests/anonymizedPullRequests.schema.ts new file mode 100644 index 0000000..290a2e1 --- /dev/null +++ b/src/database/anonymizedPullRequests/anonymizedPullRequests.schema.ts @@ -0,0 +1,66 @@ +import { Schema } from "mongoose"; + +const AnonymizedPullRequestSchema = new Schema({ + pullRequestId: { + type: String, + index: { unique: true }, + }, + status: { + type: String, + default: "preparing", + }, + statusDate: Date, + statusMessage: String, + anonymizeDate: Date, + lastView: Date, + pageView: Number, + owner: Schema.Types.ObjectId, + conference: String, + source: { + pullRequestId: Number, + repositoryFullName: String, + accessToken: String, + }, + options: { + terms: [String], + expirationMode: { type: String }, + expirationDate: Date, + update: Boolean, + image: Boolean, + link: Boolean, + title: Boolean, + body: Boolean, + comments: Boolean, + diff: Boolean, + origin: Boolean, + username: Boolean, + date: Boolean, + }, + dateOfEntry: { + type: Date, + default: new Date(), + }, + pullRequest: { + diff: String, + title: String, + body: String, + creationDate: Date, + updatedDate: Date, + draft: Boolean, + merged: Boolean, + mergedDate: Date, + state: String, + baseRepositoryFullName: String, + headRepositoryFullName: String, + comments: [ + { + body: String, + creationDate: Date, + updatedDate: Date, + author: String, + }, + ], + }, +}); + +export default AnonymizedPullRequestSchema; diff --git a/src/database/anonymizedPullRequests/anonymizedPullRequests.types.ts b/src/database/anonymizedPullRequests/anonymizedPullRequests.types.ts new file mode 100644 index 0000000..17e0c8b --- /dev/null +++ b/src/database/anonymizedPullRequests/anonymizedPullRequests.types.ts @@ -0,0 +1,61 @@ +import { Document, Model } from "mongoose"; +import { RepositoryStatus } from "../../types"; + +export interface IAnonymizedPullRequest { + pullRequestId: string; + status?: RepositoryStatus; + statusMessage?: string; + statusDate: Date; + anonymizeDate: Date; + source: { + pullRequestId: number; + repositoryFullName?: string; + accessToken?: string; + }; + owner: string; + conference: string; + options: { + terms: string[]; + expirationMode: "never" | "redirect" | "remove"; + expirationDate?: Date; + update: boolean; + image: boolean; + link: boolean; + title: boolean; + body: boolean; + comments: boolean; + diff: boolean; + origin: boolean; + username: boolean; + date: boolean; + }; + pageView: number; + lastView: Date; + pullRequest: { + diff: string; + title: string; + body: string; + creationDate: Date; + updatedDate: Date; + draft?: boolean; + merged?: boolean; + mergedDate?: Date; + state?: string; + baseRepositoryFullName?: string; + headRepositoryFullName?: string; + comments?: { + body: string; + creationDate: Date; + updatedDate: Date; + author: string; + }[]; + }; +} + +export interface IAnonymizedPullRequestDocument + extends IAnonymizedPullRequest, + Document { + setLastUpdated: (this: IAnonymizedPullRequestDocument) => Promise; +} +export interface IAnonymizedPullRequestModel + extends Model {} diff --git a/src/database/database.ts b/src/database/database.ts index 5e52d2f..3972302 100644 --- a/src/database/database.ts +++ b/src/database/database.ts @@ -3,6 +3,8 @@ import Repository from "../Repository"; import config from "../../config"; import AnonymizedRepositoryModel from "./anonymizedRepositories/anonymizedRepositories.model"; import AnonymousError from "../AnonymousError"; +import AnonymizedPullRequestModel from "./anonymizedPullRequests/anonymizedPullRequests.model"; +import PullRequest from "../PullRequest"; const MONGO_URL = `mongodb://${config.DB_USERNAME}:${config.DB_PASSWORD}@${config.DB_HOSTNAME}:27017/`; @@ -17,7 +19,7 @@ export async function connect() { } export async function getRepository(repoId: string) { - if (!repoId || repoId == 'undefined') { + if (!repoId || repoId == "undefined") { throw new AnonymousError("repo_not_found", { object: repoId, httpStatus: 404, @@ -31,3 +33,20 @@ export async function getRepository(repoId: string) { }); return new Repository(data); } +export async function getPullRequest(pullRequestId: string) { + if (!pullRequestId || pullRequestId == "undefined") { + throw new AnonymousError("pull_request_not_found", { + object: pullRequestId, + httpStatus: 404, + }); + } + const data = await AnonymizedPullRequestModel.findOne({ + pullRequestId, + }); + if (!data) + throw new AnonymousError("pull_request_not_found", { + object: pullRequestId, + httpStatus: 404, + }); + return new PullRequest(data); +} diff --git a/src/routes/index.ts b/src/routes/index.ts index 3e0174e..7cf0980 100644 --- a/src/routes/index.ts +++ b/src/routes/index.ts @@ -1,3 +1,5 @@ +import pullRequestPrivate from "./pullRequest-private"; +import pullRequestPublic from "./pullRequest-public"; import repositoryPrivate from "./repository-private"; import repositoryPublic from "./repository-public"; import conference from "./conference"; @@ -8,6 +10,8 @@ import option from "./option"; import admin from "./admin"; export default { + pullRequestPrivate, + pullRequestPublic, repositoryPrivate, repositoryPublic, file, diff --git a/src/routes/pullRequest-private.ts b/src/routes/pullRequest-private.ts new file mode 100644 index 0000000..b9dde8d --- /dev/null +++ b/src/routes/pullRequest-private.ts @@ -0,0 +1,245 @@ +import * as express from "express"; +import { ensureAuthenticated } from "./connection"; + +import { + getPullRequest, + getUser, + handleError, + isOwnerOrAdmin, +} from "./route-utils"; +import AnonymousError from "../AnonymousError"; +import { IAnonymizedPullRequestDocument } from "../database/anonymizedPullRequests/anonymizedPullRequests.types"; +import PullRequest from "../PullRequest"; +import AnonymizedPullRequestModel from "../database/anonymizedPullRequests/anonymizedPullRequests.model"; + +const router = express.Router(); + +// user needs to be connected for all user API +router.use(ensureAuthenticated); + +// refresh pullRequest +router.post( + "/:pullRequestId/refresh", + async (req: express.Request, res: express.Response) => { + try { + const pullRequest = await getPullRequest(req, res, { nocheck: true }); + if (!pullRequest) return; + + if ( + pullRequest.status == "preparing" || + pullRequest.status == "removing" || + pullRequest.status == "expiring" + ) + return; + + const user = await getUser(req); + isOwnerOrAdmin([pullRequest.owner.id], user); + await pullRequest.anonymize() + res.json({ status: pullRequest.status }); + } catch (error) { + handleError(error, res, req); + } + } +); + +// delete a pullRequest +router.delete( + "/:pullRequestId/", + async (req: express.Request, res: express.Response) => { + const pullRequest = await getPullRequest(req, res, { nocheck: true }); + if (!pullRequest) return; + try { + if (pullRequest.status == "removed") + throw new AnonymousError("is_removed", { + object: req.params.pullRequestId, + httpStatus: 410, + }); + const user = await getUser(req); + isOwnerOrAdmin([pullRequest.owner.id], user); + await pullRequest.remove(); + return res.json({ status: pullRequest.status }); + } catch (error) { + handleError(error, res, req); + } + } +); + +router.get( + "/:owner/:repository/:pullRequestId", + async (req: express.Request, res: express.Response) => { + const user = await getUser(req); + try { + const pullRequest = new PullRequest( + new AnonymizedPullRequestModel({ + owner: user.id, + source: { + pullRequestId: parseInt(req.params.pullRequestId), + repositoryFullName: `${req.params.owner}/${req.params.repository}`, + }, + }) + ); + await pullRequest.download(); + res.json(pullRequest.toJSON()); + } catch (error) { + handleError(error, res, req); + } + } +); + +// get pullRequest information +router.get( + "/:pullRequestId/", + async (req: express.Request, res: express.Response) => { + try { + const pullRequest = await getPullRequest(req, res, { nocheck: true }); + if (!pullRequest) return; + + const user = await getUser(req); + isOwnerOrAdmin([pullRequest.owner.id], user); + res.json(pullRequest.toJSON()); + } catch (error) { + handleError(error, res, req); + } + } +); + +function validateNewPullRequest(pullRequestUpdate): void { + const validCharacters = /^[0-9a-zA-Z\-\_]+$/; + if ( + !pullRequestUpdate.pullRequestId.match(validCharacters) || + pullRequestUpdate.pullRequestId.length < 3 + ) { + throw new AnonymousError("invalid_pullRequestId", { + object: pullRequestUpdate, + httpStatus: 400, + }); + } + if (!pullRequestUpdate.source.repositoryFullName) { + throw new AnonymousError("repository_not_specified", { + object: pullRequestUpdate, + httpStatus: 400, + }); + } + if (!pullRequestUpdate.source.pullRequestId) { + throw new AnonymousError("pullRequestId_not_specified", { + object: pullRequestUpdate, + httpStatus: 400, + }); + } + if ( + parseInt(pullRequestUpdate.source.pullRequestId) != + pullRequestUpdate.source.pullRequestId + ) { + throw new AnonymousError("pullRequestId_is_not_a_number", { + object: pullRequestUpdate, + httpStatus: 400, + }); + } + if (!pullRequestUpdate.options) { + throw new AnonymousError("options_not_provided", { + object: pullRequestUpdate, + httpStatus: 400, + }); + } + if (!Array.isArray(pullRequestUpdate.terms)) { + throw new AnonymousError("invalid_terms_format", { + object: pullRequestUpdate, + httpStatus: 400, + }); + } +} + +function updatePullRequestModel( + model: IAnonymizedPullRequestDocument, + pullRequestUpdate: any +) { + model.options = { + terms: pullRequestUpdate.terms, + expirationMode: pullRequestUpdate.options.expirationMode, + expirationDate: pullRequestUpdate.options.expirationDate + ? new Date(pullRequestUpdate.options.expirationDate) + : null, + update: pullRequestUpdate.options.update, + image: pullRequestUpdate.options.image, + link: pullRequestUpdate.options.link, + body: pullRequestUpdate.options.body, + title: pullRequestUpdate.options.title, + username: pullRequestUpdate.options.username, + origin: pullRequestUpdate.options.origin, + diff: pullRequestUpdate.options.diff, + comments: pullRequestUpdate.options.comments, + date: pullRequestUpdate.options.date, + }; +} + +// update a pullRequest +router.post( + "/:pullRequestId/", + async (req: express.Request, res: express.Response) => { + try { + const pullRequest = await getPullRequest(req, res, { nocheck: true }); + if (!pullRequest) return; + const user = await getUser(req); + + isOwnerOrAdmin([pullRequest.owner.id], user); + const pullRequestUpdate = req.body; + validateNewPullRequest(pullRequestUpdate); + pullRequest.model.anonymizeDate = new Date(); + + updatePullRequestModel(pullRequest.model, pullRequestUpdate); + // TODO handle conference + pullRequest.model.conference = pullRequestUpdate.conference; + await pullRequest.updateIfNeeded({ force: true }); + res.json(pullRequest.toJSON()); + } catch (error) { + return handleError(error, res, req); + } + } +); + +// add pullRequest +router.post("/", async (req: express.Request, res: express.Response) => { + const user = await getUser(req); + const pullRequestUpdate = req.body; + + try { + validateNewPullRequest(pullRequestUpdate); + + const pullRequest = new PullRequest( + new AnonymizedPullRequestModel({ + owner: user.id, + options: pullRequestUpdate.options, + }) + ); + + pullRequest.model.pullRequestId = pullRequestUpdate.pullRequestId; + pullRequest.model.anonymizeDate = new Date(); + pullRequest.model.owner = user.id; + + updatePullRequestModel(pullRequest.model, pullRequestUpdate); + pullRequest.source.accessToken = user.accessToken; + pullRequest.source.pullRequestId = pullRequestUpdate.source.pullRequestId; + pullRequest.source.repositoryFullName = + pullRequestUpdate.source.repositoryFullName; + + pullRequest.conference = pullRequestUpdate.conference; + + await pullRequest.anonymize() + res.send(pullRequest.toJSON()); + } catch (error) { + if (error.message?.indexOf(" duplicate key") > -1) { + return handleError( + new AnonymousError("pullRequestId_already_used", { + httpStatus: 400, + cause: error, + object: pullRequestUpdate, + }), + res, + req + ); + } + return handleError(error, res, req); + } +}); + +export default router; diff --git a/src/routes/pullRequest-public.ts b/src/routes/pullRequest-public.ts new file mode 100644 index 0000000..8224358 --- /dev/null +++ b/src/routes/pullRequest-public.ts @@ -0,0 +1,84 @@ +import * as express from "express"; + +import { getPullRequest, handleError } from "./route-utils"; +import AnonymousError from "../AnonymousError"; + +const router = express.Router(); + +router.get( + "/:pullRequestId/options", + async (req: express.Request, res: express.Response) => { + try { + res.header("Cache-Control", "no-cache"); + const pr = await getPullRequest(req, res, { nocheck: true }); + if (!pr) return; + let redirectURL = null; + if (pr.status == "expired" && pr.options.expirationMode == "redirect") { + redirectURL = `https://github.com/${pr.source.repositoryFullName}/pull/${pr.source.pullRequestId}`; + } else { + if ( + pr.status == "expired" || + pr.status == "expiring" || + pr.status == "removing" || + pr.status == "removed" + ) { + throw new AnonymousError("pull_request_expired", { + object: pr, + httpStatus: 410, + }); + } + + const fiveMinuteAgo = new Date(); + fiveMinuteAgo.setMinutes(fiveMinuteAgo.getMinutes() - 5); + if (pr.status != "ready") { + if ( + pr.model.statusDate < fiveMinuteAgo + // && repo.status != "preparing" + ) { + await pr.updateIfNeeded({ force: true }); + } + if (pr.status == "error") { + throw new AnonymousError( + pr.model.statusMessage + ? pr.model.statusMessage + : "pull_request_not_available", + { + object: pr, + httpStatus: 500, + } + ); + } + throw new AnonymousError("pull_request_not_ready", { + httpStatus: 404, + object: pr, + }); + } + + await pr.updateIfNeeded(); + } + + res.json({ + url: redirectURL, + lastUpdateDate: pr.model.statusDate, + }); + } catch (error) { + handleError(error, res, req); + } + } +); +router.get( + "/:pullRequestId/content", + async (req: express.Request, res: express.Response) => { + const pullRequest = await getPullRequest(req, res); + if (!pullRequest) return; + try { + await pullRequest.countView(); + res.header("Cache-Control", "no-cache"); + res.json(pullRequest.content()); + } catch (error) { + handleError(error, res, req); + } + } +); + +export default router; diff --git a/src/routes/repository-public.ts b/src/routes/repository-public.ts index 519bd68..f88c0ba 100644 --- a/src/routes/repository-public.ts +++ b/src/routes/repository-public.ts @@ -60,11 +60,10 @@ router.get( router.get( "/:repoId/files", async (req: express.Request, res: express.Response) => { + res.header("Cache-Control", "no-cache"); const repo = await getRepo(req, res); if (!repo) return; try { - res.header("Cache-Control", "no-cache"); - res.json(await repo.anonymizedFiles({ includeSha: false })); } catch (error) { handleError(error, res, req); @@ -76,6 +75,7 @@ router.get( "/:repoId/options", async (req: express.Request, res: express.Response) => { try { + res.header("Cache-Control", "no-cache"); const repo = await getRepo(req, res, { nocheck: true }); if (!repo) return; let redirectURL = null; @@ -146,7 +146,6 @@ router.get( download = true; } - res.header("Cache-Control", "no-cache"); res.json({ url: redirectURL, download, diff --git a/src/routes/route-utils.ts b/src/routes/route-utils.ts index 2092bc3..9c97500 100644 --- a/src/routes/route-utils.ts +++ b/src/routes/route-utils.ts @@ -5,6 +5,35 @@ import UserModel from "../database/users/users.model"; import User from "../User"; import * as io from "@pm2/io"; +export async function getPullRequest( + req: express.Request, + res: express.Response, + opt?: { nocheck?: boolean } +) { + try { + const pullRequest = await db.getPullRequest(req.params.pullRequestId); + if (opt?.nocheck == true) { + } else { + // redirect if the repository is expired + if ( + pullRequest.status == "expired" && + pullRequest.options.expirationMode == "redirect" + ) { + res.redirect( + `http://github.com/${pullRequest.source.repositoryFullName}/pull/${pullRequest.source.pullRequestId}` + ); + return null; + } + + pullRequest.check(); + } + return pullRequest; + } catch (error) { + handleError(error, res, req); + return null; + } +} + export async function getRepo( req: express.Request, res: express.Response, @@ -50,7 +79,7 @@ function printError(error: any, req?: express.Request) { if (req) { message += ` ${req.originalUrl}`; // ignore common error - if (req.originalUrl === '/api/repo/undefined/options') return + if (req.originalUrl === "/api/repo/undefined/options") return; } console.error(message); } else if (error instanceof Error) { diff --git a/src/routes/user.ts b/src/routes/user.ts index 73af85a..e2ee748 100644 --- a/src/routes/user.ts +++ b/src/routes/user.ts @@ -97,6 +97,21 @@ router.get( } } ); +router.get( + "/anonymized_pull_requests", + async (req: express.Request, res: express.Response) => { + try { + const user = await getUser(req); + res.json( + (await user.getPullRequests()).map((x) => { + return x.toJSON(); + }) + ); + } catch (error) { + handleError(error, res, req); + } + } +); router.get( "/all_repositories", diff --git a/src/routes/webview.ts b/src/routes/webview.ts index 21d5462..59fff13 100644 --- a/src/routes/webview.ts +++ b/src/routes/webview.ts @@ -6,7 +6,7 @@ import GitHubDownload from "../source/GitHubDownload"; import AnonymousError from "../AnonymousError"; import { TreeElement } from "../types"; import * as marked from "marked"; -import { anonymizeContent, streamToString } from "../anonymize-utils"; +import { streamToString } from "../anonymize-utils"; const router = express.Router(); @@ -103,7 +103,7 @@ async function webView(req: express.Request, res: express.Response) { } if ((await f.extension()) == "md") { const content = await streamToString(await f.anonymizedContent()); - res.send(marked.marked(content)); + res.contentType("html").send(marked.marked(content)); } else { f.send(res); } diff --git a/src/server.ts b/src/server.ts index 30b3333..7ca46d2 100644 --- a/src/server.ts +++ b/src/server.ts @@ -90,6 +90,8 @@ export default async function start() { apiRouter.use("/repo", router.repositoryPublic); apiRouter.use("/repo", speedLimiter, router.file); apiRouter.use("/repo", speedLimiter, router.repositoryPrivate); + apiRouter.use("/pr", speedLimiter, router.pullRequestPrivate); + apiRouter.use("/pr", speedLimiter, router.pullRequestPublic); apiRouter.get("/message", async (_, res) => { if (ofs.existsSync("./message.txt")) {