From 619a1ef5e6dd7545ba9b2b0c4cebaabc04588851 Mon Sep 17 00:00:00 2001 From: Marco Zille Date: Wed, 8 Nov 2023 16:45:14 +0100 Subject: [PATCH] Code refactor to prepare for supporting multiple providers --- .env.development | 7 + index.js | 20 +-- src/badges/badges.js | 39 ------ src/badges/index.js | 3 +- src/helpers/augurAPI.js | 5 + src/helpers/github.js | 281 ++++++++++++++++++++++++++++++++++++++++ src/helpers/mailer.js | 15 +++ src/routes/github.js | 100 ++++++++++++++ src/routes/index.js | 78 +++++++++++ src/routes/routes.js | 212 ------------------------------ src/scanner.js | 70 ---------- 11 files changed, 492 insertions(+), 338 deletions(-) delete mode 100644 src/badges/badges.js create mode 100644 src/helpers/github.js create mode 100644 src/routes/github.js create mode 100644 src/routes/index.js delete mode 100644 src/routes/routes.js delete mode 100644 src/scanner.js diff --git a/.env.development b/.env.development index 45cef25..a8e50ab 100644 --- a/.env.development +++ b/.env.development @@ -4,3 +4,10 @@ DB_PASSWORD=rootpwd DB_HOST=127.0.0.1 DB_DIALECT=mysql PORT=8000 +EMAIL_HOST= +EMAIL_PORT= +EMAIL_SECURE= +EMAIL_USERNAME= +EMAIL_PASSWORD= +GITHUB_APP_CLIENT_ID= +GITHUB_APP_CLIENT_SECRET= \ No newline at end of file diff --git a/index.js b/index.js index f95711b..b116f4e 100644 --- a/index.js +++ b/index.js @@ -1,9 +1,10 @@ -const express = require("express"); require("dotenv").config({ path: `.env.${process.env.NODE_ENV}` }); -const bodyParser = require("body-parser"); + const cors = require("cors"); -const routes = require("./src/routes/routes.js"); const dbconnect = require("./database/helpers/dbconnect"); +const express = require("express"); +const bodyParser = require("body-parser"); +const routes = require("./src/routes/index.js"); const app = express(); app.use(express.static("public")); @@ -17,18 +18,7 @@ app.use( }) ); -// ROUTING -app.get("/api/login", routes.login); - -if (process.env.NODE_ENV === "production") { - app.post("/api/callback", routes.productionCallback); -} else if (process.env.NODE_ENV === "development") { - app.get("/api/callback", routes.developmentCallback); -} - -app.post("/api/repos-to-badge", routes.reposToBadge); - -app.get("/api/badgedRepos", routes.badgedRepos); +routes.setupRoutes(app); (async () => { try { diff --git a/src/badges/badges.js b/src/badges/badges.js deleted file mode 100644 index 66ba889..0000000 --- a/src/badges/badges.js +++ /dev/null @@ -1,39 +0,0 @@ -const bronzeBadge = require("./bronzeBadge"); - -const badges = async (req, res, login, name, email, octokit) => { - let deiFilePresent = false; // track presence of DEI file - - try { - const selectedRepositories = JSON.parse(req.body.repositories); - - // const octokit = new Octokit(); - for (const repo of selectedRepositories) { - const [owner, repoName] = repo.split("/"); - try { - const { data: DEI } = await octokit.repos.getContent({ - owner, - repo: repoName, - path: "DEI.md", - }); - - if (DEI && DEI.content) { - deiFilePresent = true; - // scan repo from here using ./scanner.js - bronzeBadge(owner, octokit, email, DEI); - } - } catch (e) { - console.error("Error: ", e.message); - res.status(500).send("Interval Server Error"); - } - } - - if (!deiFilePresent) { - res.json("DEI.md file was not found"); - } - } catch (e) { - console.error("Error:", e.message); - res.status(500).send("Internal Server Error"); - } -}; - -module.exports = badges; diff --git a/src/badges/index.js b/src/badges/index.js index 47abda4..5ded136 100644 --- a/src/badges/index.js +++ b/src/badges/index.js @@ -1,7 +1,6 @@ -const badges = require("./badges"); const bronzeBadge = require("./bronzeBadge"); const silverBadge = require("./silverBadge"); const goldBadge = require("./goldBadge"); const platinumBadge = require("./platinumBadge"); -module.exports = { badges, bronzeBadge, silverBadge, goldBadge, platinumBadge }; +module.exports = { bronzeBadge, silverBadge, goldBadge, platinumBadge }; diff --git a/src/helpers/augurAPI.js b/src/helpers/augurAPI.js index 4a3988d..c998428 100644 --- a/src/helpers/augurAPI.js +++ b/src/helpers/augurAPI.js @@ -2,6 +2,11 @@ const axios = require("axios"); require("dotenv").config({ path: `.env.${process.env.NODE_ENV}` }); const augurAPI = async (id, level, url) => { + if (!process.env.AUGUR_API_KEY) { + console.error("AUGUR_API_KEY not provided"); + return; + } + try { const apiUrl = "https://projectbadge.chaoss.io/api/unstable/dei/repo/add"; const apiKey = process.env.AUGUR_API_KEY; diff --git a/src/helpers/github.js b/src/helpers/github.js new file mode 100644 index 0000000..58a9925 --- /dev/null +++ b/src/helpers/github.js @@ -0,0 +1,281 @@ +const { Octokit } = require("@octokit/rest"); +const axios = require("axios"); +const Repo = require("../../database/models/repo.model.js"); +const bronzeBadge = require("../badges/bronzeBadge.js"); +const mailer = require("../helpers/mailer.js"); + +/** + * Starts the authorization process with the GitHub OAuth system + * @param {*} res Response to send back to the caller + */ +const authorizeApplication = (res) => { + if (!process.env.GITHUB_APP_CLIENT_ID) { + res.status(500).send("GitHub provider is not configured"); + return; + } + + const scopes = ["user", "repo"]; + const url = `https://github.com/login/oauth/authorize?client_id=${ + process.env.GITHUB_APP_CLIENT_ID + }&scope=${scopes.join(",")}`; + + res.redirect(url); +}; + +/** + * Calls the GitHub API to get an access token from the OAuth code. + * @param {*} code Code returned by the GitHub OAuth authorization API + * @returns A json object with `access_token` and `errors` + */ +const requestAccessToken = async (code) => { + try { + const { + data: { access_token }, + } = await axios.post( + "https://github.com/login/oauth/access_token", + { + client_id: process.env.GITHUB_APP_CLIENT_ID, + client_secret: process.env.GITHUB_APP_CLIENT_SECRET, + code, + }, + { + headers: { + Accept: "application/json", + }, + } + ); + + return { + access_token, + errors: [], + }; + } catch (error) { + return { + access_token: "", + errors: [error.message], + }; + } +}; + +/** + * Calls the GitHub API to get the user info. + * @param {*} octokit Octokit instance with autorization already set up + * @returns A json object with `user_info` and `errors` + */ +const getUserInfo = async (octokit) => { + try { + // Authenticated user details + const response = await octokit.users.getAuthenticated(); + const { + data: { login, name, email, id }, + } = response; + + return { + user_info: { + login, + name, + email, + id, + }, + errors: [], + }; + } catch (error) { + return { + user_info: null, + errors: [error.message], + }; + } +}; + +/** + * Calls the GitHub API to get the user public repositories. + * @param {*} octokit Octokit instance with autorization already set up + * @returns A json object with `repositories` and `errors` + */ +const getUserRepositories = async (octokit) => { + try { + // Public repos they maintain, administer, or own + let repos = []; + let page = 1; + let response = await octokit.repos.listForAuthenticatedUser({ + visibility: "public", + per_page: 100, + page, + }); + + while (response.data.length > 0) { + repos = [...repos, ...response.data]; + page++; + response = await octokit.repos.listForAuthenticatedUser({ + visibility: "public", + per_page: 100, + page, + }); + } + + return { + repositories: repos.map((repo) => repo.full_name), + errors: [], + }; + } catch (error) { + return { + repositories: null, + errors: [error.message], + }; + } +}; + +/** + * Get the id and url of the provided repository path + * @param {*} octokit An Octokit instance + * @param {*} owner The (username) owner of the repository + * @param {*} repositoryPath The path to the repository, without the owner prefix + * @returns A json object with `info` (the repository infos) and `errors` + */ +const getRepositoryInfo = async (octokit, owner, repositoryPath) => { + try { + const { + data: { id, html_url }, + } = await octokit.repos.get({ owner, repo: repositoryPath }); + + return { + info: { + id, + url: html_url, + }, + errors: [], + }; + } catch (error) { + return { + info: null, + errors: [error.message], + }; + } +}; + +/** + * Get the content and commit SHA of a file inside a repository + * @param {*} octokit An Octokit instance + * @param {*} owner The (username) owner of the repository + * @param {*} repositoryPath The path to the repository, without the owner prefix + * @param {*} filePath The path to the file inside the repository + * @returns A json object with `file` (SHA and content) and `errors` + */ +const getFileContentAndSHA = async ( + octokit, + owner, + repositoryPath, + filePath +) => { + try { + const { + data: { sha, content }, + } = await octokit.repos.getContent({ + owner, + repo: repositoryPath, + path: filePath, + }); + + return { + file: { + sha, + content: Buffer.from(content, "base64").toString(), + }, + errors: [], + }; + } catch (error) { + return { + file: null, + errors: [error.message], + }; + } +}; + +/** + * Scans a list of repositories to try and apply for a badge + * @param {*} name Full name of the user + * @param {*} email User email used to send them emails with the results + * @param {*} repositories List of repositories to scan + */ +const scanRepositories = async (name, email, repositories) => { + const octokit = new Octokit(); + let results = []; + + try { + for (const repository of repositories) { + const owner = repository.split("/")[0]; + const repositoryPath = repository.split("/")[1]; + + const { info, errors: info_errors } = await getRepositoryInfo( + octokit, + owner, + repositoryPath + ); + if (info_errors.length > 0) { + console.error(info_errors); + continue; + } + + const { file, errors: file_errors } = await getFileContentAndSHA( + octokit, + owner, + repositoryPath, + "DEI.md" + ); + if (file_errors.length > 0) { + results.push(`${info.url} does not have a DEI.md file`); + continue; + } + + try { + // Check if the repo was badged before + const existingRepo = await Repo.findOne({ + where: { githubRepoId: info.id }, + }); + + if (file.content) { + if (existingRepo) { + // Compare the DEICommitSHA with the existing repo's DEICommitSHA + if (existingRepo.DEICommitSHA !== file.sha) { + bronzeBadge( + name, + email, + info.id, + info.url, + file.content, + file.sha + ); + } else { + // Handle case when DEI.md file is not changed + results.push(`${info.url} was already badged`); + } + } else { + // Repo not badged before, badge it + bronzeBadge(name, email, info.id, info.url, file.content, file.sha); + } + } + } catch (error) { + console.error(error.message); + } + } + + // Send one single email for generic errors while processing repositories + // The `bronzeBadge` function will handle sending email for each project + // with wether success or error messages + if (results.length > 0) { + mailer(email, name, "Bronze", null, null, results.join("\n")); + } + } catch (error) { + console.error("Error: ", error.message); + } + + return results; +}; + +module.exports = { + authorizeApplication, + requestAccessToken, + getUserInfo, + getUserRepositories, + scanRepositories, +}; diff --git a/src/helpers/mailer.js b/src/helpers/mailer.js index ace8775..9cfd93b 100644 --- a/src/helpers/mailer.js +++ b/src/helpers/mailer.js @@ -11,6 +11,21 @@ const mailer = async ( htmlLink, results ) => { + if ( + !process.env.EMAIL_HOST || + !process.env.EMAIL_HOST || + !process.env.EMAIL_USERNAME || + !process.env.EMAIL_PASSWORD + ) { + console.error("Email service is not configured"); + + if (process.env.NODE_ENV === "development") { + console.log(`Sending email to '${email}'`, results); + } + + return; + } + // Create a transporter using your email service provider's SMTP settings const transporter = nodemailer.createTransport({ service: "Gmail", diff --git a/src/routes/github.js b/src/routes/github.js new file mode 100644 index 0000000..f8b6618 --- /dev/null +++ b/src/routes/github.js @@ -0,0 +1,100 @@ +const { Octokit } = require("@octokit/rest"); + +const saveUser = require("../../database/controllers/user.controller.js"); +const github_helper = require("../helpers/github.js"); + +const handleOAuthCallback = async (req, res) => { + const code = req.body.code ?? req.query.code; + + const { access_token, errors: access_token_errors } = + await github_helper.requestAccessToken(code); + if (access_token_errors.length > 0) { + res.status(500).send(access_token_errors.join()); + return; + } + + const octokit = new Octokit({ auth: `${access_token}` }); + + // Authenticated user details + const { user_info, errors: user_info_errors } = + await github_helper.getUserInfo(octokit); + if (user_info_errors.length > 0) { + res.status(500).send(user_info_errors.join()); + return; + } + + // Save user to database + await saveUser( + user_info.login, + user_info.name, + user_info.email, + user_info.id + ); + + // Public repos they maintain, administer, or own + const { repositories, errors: repositories_errors } = + await github_helper.getUserRepositories(octokit); + if (repositories_errors.length > 0) { + res.status(500).send(repositories_errors.join()); + return; + } + + if (process.env.NODE_ENV === "production") { + res.status(200).json({ + name: user_info.name, + username: user_info.login, + email: user_info.email, + repos: repositories, + provider: "github", + }); + } else if (process.env.NODE_ENV === "development") { + res.status(200).send(` + + + Repo List + + +

Welcome ${user_info.name}

+

Username: ${user_info.login}

+

Email: ${user_info.email}

+
+ + + +

Select Repositories:

+ ${repositories + .map( + (repo) => ` +
+ + +
+ ` + ) + .join("")} +
+ +
+ + + `); + } else { + res.status(500).send("Unknown process mode"); + } +}; + +/** + * Sets up the provided Express app routes for GitHub + * @param {*} app Express application instance + */ +const setupGitHubRoutes = (app) => { + if (process.env.NODE_ENV === "production") { + app.post("/api/callback/github", handleOAuthCallback); + } else if (process.env.NODE_ENV === "development") { + app.get("/api/callback/github", handleOAuthCallback); + } +}; + +module.exports = { + setupGitHubRoutes, +}; diff --git a/src/routes/index.js b/src/routes/index.js new file mode 100644 index 0000000..2dc7c77 --- /dev/null +++ b/src/routes/index.js @@ -0,0 +1,78 @@ +const Repo = require("../../database/models/repo.model.js"); +const github_helpers = require("../helpers/github.js"); +const github_routes = require("./github.js"); + +/** + * Redirects the user to the GitHub OAuth login page for authentication. + * @param {*} req - object containing the client req details. + * @param {*} res - object used to send a redirect response. + */ +const login = (req, res) => { + const provider = req.query.provider; + + if (provider === "github") { + github_helpers.authorizeApplication(res); + } else { + res.status(400).send(`Unknown provider: ${provider}`); + } +}; + +const reposToBadge = async (req, res) => { + const selectedRepos = (await req.body.repos) || []; + const name = req.body.name || ""; + const email = req.body.email || ""; + const provider = req.body.provider; + + if (!provider) { + res.status(400).send("provider missing"); + } + + // Process the selected repos as needed + if (provider === "github") { + const results = await github_helpers.scanRepositories( + name, + email, + selectedRepos + ); + res.status(200).json({ results }); + } else { + res.status(400).send(`Unknown provider: ${provider}`); + } +}; + +const badgedRepos = async (req, res) => { + try { + // Use Sequelize to find all repos, excluding the DEICommitSHA field + const repos = await Repo.findAll({ + attributes: { exclude: ["DEICommitSHA"] }, + }); + + // Extract the relevant information from the repos + const formattedRepos = repos.map((repo) => ({ + id: repo.id, + githubRepoId: repo.githubRepoId, + repoLink: repo.repoLink, + badgeType: repo.badgeType, + attachment: repo.attachment, + createdAt: repo.createdAt, + updatedAt: repo.updatedAt, + userId: repo.userId, + })); + + res.json(formattedRepos); + } catch (error) { + res.status(500).json({ message: "Error retrieving repos", error }); + } +}; + +const setupRoutes = (app) => { + app.get("/api/login", login); + app.get("/api/badgedRepos", badgedRepos); + app.post("/api/repos-to-badge", reposToBadge); + + github_routes.setupGitHubRoutes(app); +}; + +module.exports = { + setupRoutes, +}; diff --git a/src/routes/routes.js b/src/routes/routes.js deleted file mode 100644 index a0ce489..0000000 --- a/src/routes/routes.js +++ /dev/null @@ -1,212 +0,0 @@ -const { Octokit } = require("@octokit/rest"); -const axios = require("axios"); -const scanner = require("../scanner.js"); -const saveUser = require("../../database/controllers/user.controller.js"); -const Repo = require("../../database/models/repo.model.js"); - -/** - * Redirects the user to the GitHub OAuth login page for authentication. - * @param {*} req - object containing the client req details. - * @param {*} res - object used to send a redirect response. - */ -const login = (req, res) => { - const scopes = ["user", "repo"]; - const url = `https://github.com/login/oauth/authorize?client_id=${ - process.env.CLIENT_ID - }&scope=${scopes.join(",")}`; - - res.redirect(url); -}; - -/** - * Receives the code from GitHub and uses it to request an access token. - * @param {*} req - object containing the client req details. - * @param {*} res - object used to send a response. - */ - -const productionCallback = async (req, res) => { - try { - const { code } = req.body; - const { - data: { access_token }, - } = await axios.post( - "https://github.com/login/oauth/access_token", - { - client_id: process.env.CLIENT_ID, - client_secret: process.env.CLIENT_SECRET, - code, - }, - { - headers: { - Accept: "application/json", - }, - } - ); - - const octokit = new Octokit({ auth: `${access_token}` }); - - // Authenticated user details - const { - data: { login, name, email, id: githubId }, - } = await octokit.users.getAuthenticated(); - - // save user to database - await saveUser(login, name, email, githubId); - - // Public repos they maintain, administer, or own - let repos = []; - let page = 1; - let response = await octokit.repos.listForAuthenticatedUser({ - visibility: "public", - per_page: 100, - page, - }); - - while (response.data.length > 0) { - repos = [...repos, ...response.data]; - page++; - response = await octokit.repos.listForAuthenticatedUser({ - visibility: "public", - per_page: 100, - page, - }); - } - - const repoList = repos.map((repo) => repo.full_name); - - res.status(200).json({ - name, - username: login, - email, - repos: repoList, - }); - } catch (error) { - res.status(500).send(error.message); - } -}; - -const developmentCallback = async (req, res) => { - try { - const { code } = req.query; - const { - data: { access_token }, - } = await axios.post( - "https://github.com/login/oauth/access_token", - { - client_id: process.env.CLIENT_ID, - client_secret: process.env.CLIENT_SECRET, - code, - }, - { - headers: { - Accept: "application/json", - }, - } - ); - - const octokit = new Octokit({ auth: `${access_token}` }); - - // Authenticated user details - const { - data: { login, name, email, id: githubId }, - } = await octokit.users.getAuthenticated(); - - // save user to database - await saveUser(login, name, email, githubId); - - // Public repos they maintain, administer, or own - let repos = []; - let page = 1; - let response = await octokit.repos.listForAuthenticatedUser({ - visibility: "public", - per_page: 100, - page, - }); - - while (response.data.length > 0) { - repos = [...repos, ...response.data]; - page++; - response = await octokit.repos.listForAuthenticatedUser({ - visibility: "public", - per_page: 100, - page, - }); - } - - const repoList = repos.map((repo) => repo.full_name); - - res.status(200).send(` - - - Repo List - - -

Welcome ${name}

-

Username: ${login}

-

Email: ${email}

-
- - -

Select Repositories:

- ${repoList - .map( - (repo) => ` -
- - -
- ` - ) - .join("")} -
- -
- - - `); - } catch (error) { - res.status(500).send(error.message); - } -}; - -const reposToBadge = async (req, res) => { - const selectedRepos = (await req.body.repos) || []; - const name = req.body.name || ""; - const email = req.body.email || ""; - // Process the selected repos as needed - const results = await scanner(name, email, selectedRepos); - res.status(200).json({ results }); -}; - -const badgedRepos = async (req, res) => { - try { - // Use Sequelize to find all repos, excluding the DEICommitSHA field - const repos = await Repo.findAll({ - attributes: { exclude: ["DEICommitSHA"] }, - }); - - // Extract the relevant information from the repos - const formattedRepos = repos.map((repo) => ({ - id: repo.id, - githubRepoId: repo.githubRepoId, - repoLink: repo.repoLink, - badgeType: repo.badgeType, - attachment: repo.attachment, - createdAt: repo.createdAt, - updatedAt: repo.updatedAt, - userId: repo.userId, - })); - - res.json(formattedRepos); - } catch (error) { - res.status(500).json({ message: "Error retrieving repos", error }); - } -}; - -module.exports = { - login, - productionCallback, - developmentCallback, - reposToBadge, - badgedRepos, -}; diff --git a/src/scanner.js b/src/scanner.js deleted file mode 100644 index 6667ff4..0000000 --- a/src/scanner.js +++ /dev/null @@ -1,70 +0,0 @@ -const { Octokit } = require("@octokit/rest"); -const mailer = require("./helpers/mailer.js"); -const bronzeBadge = require("./badges/bronzeBadge.js"); -const Repo = require("../database/models/repo.model"); - -const scanner = async (name, email, selectedRepos) => { - const octokit = new Octokit(); - let results = []; - - try { - for (const repo of selectedRepos) { - try { - const repoResponse = await octokit.repos.get({ - owner: repo.split("/")[0], - repo: repo.split("/")[1], - }); - - const id = repoResponse.data.id; - const url = repoResponse.data.html_url; - let DEICommitSHA, - content = null; - - try { - const contentResponse = await octokit.repos.getContent({ - owner: repo.split("/")[0], - repo: repo.split("/")[1], - path: "DEI.md", - }); - - DEICommitSHA = contentResponse.data.sha; - content = Buffer.from( - contentResponse.data.content, - "base64" - ).toString(); - } catch (error) { - results.push(`${url} does not have a DEI.md file`); - mailer(email, name, "Bronze", null, null, results.join("\n")); - return results; - } - - // Check if the repo was badged before - const existingRepo = await Repo.findOne({ - where: { githubRepoId: id }, - }); - - if (content) { - if (existingRepo) { - // Compare the DEICommitSHA with the existing repo's DEICommitSHA - if (existingRepo.DEICommitSHA !== DEICommitSHA) { - bronzeBadge(name, email, id, url, content, DEICommitSHA); - } else { - // Handle case when DEI.md file is not changed - results.push(`${url} was already badged `); - mailer(email, name, "Bronze", null, null, results.join("\n")); - } - } else { - // Repo not badged before, badge it - bronzeBadge(name, email, id, url, content, DEICommitSHA); - } - } - } catch (error) { - console.error(error.message); - } - } - } catch (error) { - console.error("Error:", error.message); - } -}; - -module.exports = scanner;