From 3b99a029c3946931e1da9f3d0e20f72aef1f58fe Mon Sep 17 00:00:00 2001 From: Samet Date: Mon, 6 Mar 2023 00:58:26 +0300 Subject: [PATCH 1/7] feat: moved the OAuth providers into folders MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit migrated the Github OAuth to Github Apps ― it brings GraphQL API suppport 🥳 --- components/svgs/github/index.tsx | 6 +- pages/api/oauth/[...route].ts | 8 +-- services/api/handler.ts | 12 +++- services/oauth/actions.ts | 2 +- services/oauth/index.ts | 44 ++++++++++++ services/oauth/providers.ts | 83 ----------------------- services/oauth/providers/github.ts | 53 +++++++++++++++ services/oauth/providers/stackoverflow.ts | 40 +++++++++++ services/oauth/providers/wakatime.ts | 53 +++++++++++++++ services/platform/codewars/index.ts | 2 + services/platform/codewars/request.ts | 2 +- services/platform/github/index.ts | 22 +++--- services/platform/github/request.ts | 5 +- services/platform/response.ts | 7 +- services/platform/stackoverflow/index.ts | 24 ++----- services/platform/wakatime/index.ts | 4 +- services/prisma/schema.prisma | 8 +-- 17 files changed, 237 insertions(+), 138 deletions(-) create mode 100644 services/oauth/index.ts delete mode 100644 services/oauth/providers.ts create mode 100644 services/oauth/providers/github.ts create mode 100644 services/oauth/providers/stackoverflow.ts create mode 100644 services/oauth/providers/wakatime.ts diff --git a/components/svgs/github/index.tsx b/components/svgs/github/index.tsx index 6519362..1e028aa 100644 --- a/components/svgs/github/index.tsx +++ b/components/svgs/github/index.tsx @@ -8,7 +8,7 @@ import { export const getCurrentYearContributions = (result: any, platform: any) => { const { totalContributions } = - result.data.user.contributionsCollection.contributionCalendar; + result.data.viewer.contributionsCollection.contributionCalendar; return ( @@ -25,7 +25,7 @@ export const getCurrentYearContributions = (result: any, platform: any) => { }; export const getPopularContributions = (result: any) => { - const popularContributions = result.data.user.contributionsCollection; + const popularContributions = result.data.viewer.contributionsCollection; const [popularIssue, popularPullrequest] = [ popularContributions.popularIssueContribution.issue, popularContributions.popularPullRequestContribution.pullRequest, @@ -58,7 +58,7 @@ export const getContributionsSummary = (result: any) => { totalRepositoriesWithContributedCommits, totalRepositoriesWithContributedPullRequests, totalRepositoriesWithContributedIssues, - } = result.data.user.contributionsCollection; + } = result.data.viewer.contributionsCollection; return ( diff --git a/pages/api/oauth/[...route].ts b/pages/api/oauth/[...route].ts index ecdf047..b8871bd 100644 --- a/pages/api/oauth/[...route].ts +++ b/pages/api/oauth/[...route].ts @@ -3,7 +3,7 @@ import { unstable_getServerSession } from "next-auth"; import { generateRandomString } from "@utils"; import { authOptions } from "@pages/api/auth/[...nextauth]"; -import Providers from "@services/oauth/providers"; +import { providers } from "@services/oauth"; import actions from "@services/oauth/actions"; export default async function handler( @@ -16,11 +16,11 @@ export default async function handler( const [action, platform]: string[] = req.query.route as string[]; if (req.method !== "GET") return res.status(405).end(); - if (Object.keys(Providers).indexOf(platform) === -1) + if (Object.keys(providers).indexOf(platform) === -1) return res.status(404).send("Not Found"); if (action === "callback") { - const provider = Providers[platform]; + const provider = providers[platform]; const params = provider.getTokenParam(provider, req.query); try { @@ -40,7 +40,7 @@ export default async function handler( } if (action === "connect") { - const provider = Providers[platform]; + const provider = providers[platform]; const redirect_uri = provider.authorization.authorizeURL({ redirect_uri: provider.redirect_uri, scope: provider.scope, diff --git a/services/api/handler.ts b/services/api/handler.ts index 3daf923..cd95a62 100644 --- a/services/api/handler.ts +++ b/services/api/handler.ts @@ -18,6 +18,9 @@ const handlePlatformAPI: PlatformAPIHandler = ( .status(400) .json({ message: "Bad request: uid parameter is missing" }); + const user = await prisma.user.findFirst({ where: { id: uid } }); + if (!user) return res.status(404).json({ message: "User not found" }); + const platform = await prisma.platform.findFirst({ where: { code: platformCode }, }); @@ -28,18 +31,21 @@ const handlePlatformAPI: PlatformAPIHandler = ( select: { value: true, platform: { select: { name: true } } }, where: { userId: uid, platformId: platform.id }, }); + const connection = await prisma.connection.findFirst({ where: { userId: uid, platformId: platform.id }, }); if (!connection) - return res.status(404).json({ message: "No user config or connection" }); + return res + .status(404) + .json({ message: "User has no connection on this platform" }); const result = await getPlatformResponse( req.query, services, templates, - userConfig?.value, - connection + connection, + userConfig ); if (result.success === false) return res.status(result.status).json({ message: result.error }); diff --git a/services/oauth/actions.ts b/services/oauth/actions.ts index 8d1d5be..1dc6a65 100644 --- a/services/oauth/actions.ts +++ b/services/oauth/actions.ts @@ -1,7 +1,7 @@ import prisma from "@services/prisma"; import { AccessToken } from "simple-oauth2"; import { Session } from "next-auth"; -import { Provider, ConnectionProfile } from "./providers"; +import { Provider, ConnectionProfile } from "."; type ISignIn = { accessToken: AccessToken; diff --git a/services/oauth/index.ts b/services/oauth/index.ts new file mode 100644 index 0000000..b224cdc --- /dev/null +++ b/services/oauth/index.ts @@ -0,0 +1,44 @@ +import { AuthorizationCode, AccessToken, Token } from "simple-oauth2"; +import wakatime from "./providers/wakatime"; +import github from "./providers/github"; +import stackoverflow from "./providers/stackoverflow"; + +export type ConnectionProfile = { + name: string; + email: string; + image: string; +}; + +export type Provider = { + code: string; + client: { + id: string; + secret: string; + }; + authorization: AuthorizationCode; + getTokenParam: ( + provider: any, + params: any + ) => { + code: string; + redirect_uri: string; + + client_id?: string; + client_secret?: string; + + state?: string; + grant_type?: string; + login?: string; + allow_signup?: boolean; + }; + connect_url?: string; + getProfile: (token: Token) => Promise; + redirect_uri: string; + scope: string; +}; + +export const providers: { [key: string]: Provider } = { + wakatime, + github, + stackoverflow, +}; diff --git a/services/oauth/providers.ts b/services/oauth/providers.ts deleted file mode 100644 index a793676..0000000 --- a/services/oauth/providers.ts +++ /dev/null @@ -1,83 +0,0 @@ -import { AuthorizationCode, AccessToken, Token } from "simple-oauth2"; - -export type ConnectionProfile = { - name: string; - email: string; - image: string; -}; - -export type Provider = { - code: string; - client: { - id: string; - secret: string; - }; - authorization: AuthorizationCode; - getTokenParam: ( - provider: any, - params: any - ) => { - code: string; - grant_type: string; - client_secret: string; - client_id: string; - redirect_uri: string; - }; - getProfile: (token: Token) => Promise; - redirect_uri: string; - scope: string; -}; - -const providers: { [key: string]: Provider } = { - wakatime: { - code: "wakatime", - client: { - id: process.env.WAKATIME_CLIENT_ID as string, - secret: process.env.WAKATIME_SECRET as string, - }, - authorization: new AuthorizationCode({ - client: { - id: process.env.WAKATIME_CLIENT_ID as string, - secret: process.env.WAKATIME_SECRET as string, - }, - auth: { - tokenHost: "https://wakatime.com", - tokenPath: "/oauth/token", - authorizePath: "/oauth/authorize", - }, - }), - getTokenParam: (provider, params) => { - return { - code: params.code as string, - grant_type: "authorization_code", - client_secret: provider.client.secret, - client_id: provider.client.id, - redirect_uri: provider.redirect_uri, - }; - }, - getProfile: async (token) => { - try { - const response = await fetch( - "https://wakatime.com/api/v1/users/current", - { - headers: { Authorization: `Bearer ${token.access_token}` }, - } - ); - const { data } = await response.json(); - - return { - name: data.full_name || data.display_name, - email: data.email, - image: data.photo, - }; - } catch (err) { - if (err instanceof Error) - console.error(`Error getting profile from wakatime: ${err.message}`); - } - }, - redirect_uri: `${process.env.AUTH_URL}/api/oauth/callback/wakatime`, - scope: "email,read_stats,read_logged_time", - }, -}; - -export default providers; diff --git a/services/oauth/providers/github.ts b/services/oauth/providers/github.ts new file mode 100644 index 0000000..348cd93 --- /dev/null +++ b/services/oauth/providers/github.ts @@ -0,0 +1,53 @@ +import { AuthorizationCode } from "simple-oauth2"; +import { Provider } from "@services/oauth"; + +const config: Provider = { + code: "github", + client: { + id: process.env.GITHUB_CONNECTOR_ID as string, + secret: process.env.GITHUB_CONNECTOR_SECRET as string, + }, + authorization: new AuthorizationCode({ + client: { + id: process.env.GITHUB_CONNECTOR_ID as string, + secret: process.env.GITHUB_CONNECTOR_SECRET as string, + }, + auth: { + tokenHost: "https://github.com", + authorizePath: "/login/oauth/authorize", + tokenPath: "/login/oauth/access_token", + }, + }), + getTokenParam: (provider, params) => { + return { + code: params.code as string, + grant_type: "authorization_code", + client_secret: provider.client.secret, + state: params.state as string, + client_id: provider.client.id, + redirect_uri: provider.redirect_uri, + }; + }, + getProfile: async (token) => { + try { + const response = await fetch("https://api.github.com/user", { + headers: { Authorization: `token ${token.access_token}` }, + }); + const data = await response.json(); + + return { + name: data.name || data.login, + email: data.email, + image: data.avatar_url, + }; + } catch (err) { + if (err instanceof Error) + console.error(`Error getting profile from github: ${err.message}`); + } + }, + connect_url: process.env.GITHUB_CONNECTOR_INSTALL_URL, + redirect_uri: `${process.env.AUTH_URL}/api/oauth/callback/github`, + scope: "read:user user:email repo", +}; + +export default config; diff --git a/services/oauth/providers/stackoverflow.ts b/services/oauth/providers/stackoverflow.ts new file mode 100644 index 0000000..a18cf31 --- /dev/null +++ b/services/oauth/providers/stackoverflow.ts @@ -0,0 +1,40 @@ +import { AuthorizationCode } from "simple-oauth2"; +import { Provider } from "@services/oauth"; + +const config: Provider = { + code: "stackoverflow", + client: { + id: process.env.STACKAPPS_CLIENT_ID as string, + secret: process.env.STACKAPPS_SECRET as string, + }, + authorization: new AuthorizationCode({ + client: { + id: process.env.STACKAPPS_CLIENT_ID as string, + secret: process.env.STACKAPPS_SECRET as string, + }, + auth: { + tokenHost: "https://stackoverflow.com/", + authorizePath: "/oauth", + tokenPath: "/oauth/access_token", + }, + }), + getTokenParam: (provider, params) => { + return { + client_id: provider.client.id, + client_secret: provider.client.secret, + code: params.code, + redirect_uri: provider.redirect_uri, + }; + }, + getProfile: async (token) => { + return Promise.resolve({ + name: "test", + email: "", + image: "", + }); + }, + redirect_uri: `${process.env.AUTH_URL}/api/oauth/callback/stackoverflow`, + scope: "private_info", +}; + +export default config; diff --git a/services/oauth/providers/wakatime.ts b/services/oauth/providers/wakatime.ts new file mode 100644 index 0000000..358cf28 --- /dev/null +++ b/services/oauth/providers/wakatime.ts @@ -0,0 +1,53 @@ +import { AuthorizationCode } from "simple-oauth2"; +import { Provider } from "@services/oauth"; + +const config: Provider = { + code: "wakatime", + client: { + id: process.env.WAKATIME_CLIENT_ID as string, + secret: process.env.WAKATIME_SECRET as string, + }, + authorization: new AuthorizationCode({ + client: { + id: process.env.WAKATIME_CLIENT_ID as string, + secret: process.env.WAKATIME_SECRET as string, + }, + auth: { + tokenHost: "https://wakatime.com", + authorizePath: "/oauth/authorize", + tokenPath: "/oauth/token", + }, + }), + getTokenParam: (provider, params) => { + return { + code: params.code as string, + grant_type: "authorization_code", + client_secret: provider.client.secret, + state: params.state as string, + client_id: provider.client.id, + redirect_uri: provider.redirect_uri, + }; + }, + getProfile: async (token) => { + try { + const response = await fetch( + "https://wakatime.com/api/v1/users/current", + { headers: { Authorization: `Bearer ${token.access_token}` } } + ); + const { data } = await response.json(); + + return { + name: data.full_name || data.display_name, + email: data.email, + image: data.photo, + }; + } catch (err) { + if (err instanceof Error) + console.error(`Error getting profile from wakatime: ${err.message}`); + } + }, + redirect_uri: `${process.env.AUTH_URL}/api/oauth/callback/wakatime`, + scope: "email,read_stats,read_logged_time", +}; + +export default config; diff --git a/services/platform/codewars/index.ts b/services/platform/codewars/index.ts index 5a655b8..fedcfc5 100644 --- a/services/platform/codewars/index.ts +++ b/services/platform/codewars/index.ts @@ -1,6 +1,7 @@ import { ServiceResponse } from "@services/platform/types"; import request from "@services/platform/codewars/request"; import { CodewarsUserConfig } from "@services/platform/types"; +import { Connection } from "@prisma/client"; /** * @name getUser @@ -8,6 +9,7 @@ import { CodewarsUserConfig } from "@services/platform/types"; * @description Get a summary of your scores and top languages */ export const getUser = async ( + connection: Connection, userConfig: CodewarsUserConfig ): Promise => { const response = await request(`/users/${userConfig.username}`); diff --git a/services/platform/codewars/request.ts b/services/platform/codewars/request.ts index d74e300..a73923d 100644 --- a/services/platform/codewars/request.ts +++ b/services/platform/codewars/request.ts @@ -5,7 +5,7 @@ * @throws {Error} - If the response is an error */ -export default function request(path: string) { +export default function request(path: string): Promise { return fetch(`https://www.codewars.com/api/v1${path}`) .then((res) => { if (res.headers.get("content-type")?.includes("application/json")) { diff --git a/services/platform/github/index.ts b/services/platform/github/index.ts index 1142ae0..a99430a 100644 --- a/services/platform/github/index.ts +++ b/services/platform/github/index.ts @@ -1,9 +1,7 @@ import { ServiceResponse } from "@services/platform/types"; import request from "@services/platform/github/request"; import { GithubUserConfig } from "@services/platform/types"; - -// Github GraphQL API Explorer can be used to discover -// https://docs.github.com/en/graphql/overview/explorer +import { Connection } from "@prisma/client"; /** * @name getCurrentYearContributions @@ -11,16 +9,16 @@ import { GithubUserConfig } from "@services/platform/types"; * @description Get the total number of contributions for the current year */ export const getCurrentYearContributions = async ( - userConfig: GithubUserConfig + connection: Connection ): Promise => { - const query = `{ user(login: "${userConfig.username}") { + const query = `{ viewer { contributionsCollection { contributionCalendar { totalContributions } } } }`; - const response = await request(query, userConfig.token); + const response = await request(query, connection); if ("error" in response) return response; return { success: true, data: response.data, platform: "github" }; }; @@ -31,9 +29,9 @@ export const getCurrentYearContributions = async ( * @description List your most popular contributions overall */ export const getPopularContributions = async ( - userConfig: GithubUserConfig + connection: Connection ): Promise => { - const query = `{ user(login: "${userConfig.username}") { + const query = `{ viewer { contributionsCollection{ popularIssueContribution{ isRestricted @@ -47,7 +45,7 @@ export const getPopularContributions = async ( } }`; - const response = await request(query, userConfig.token); + const response = await request(query, connection); if ("error" in response) return response; return { success: true, data: response.data, platform: "github" }; }; @@ -58,9 +56,9 @@ export const getPopularContributions = async ( * @description Get a summary of your contributions, like count of commits, PRs and issues */ export const getContributionsSummary = async ( - userConfig: GithubUserConfig + connection: Connection ): Promise => { - const query = `{user(login: "${userConfig.username}") { + const query = `{viewer{ contributionsCollection{ totalRepositoryContributions totalRepositoriesWithContributedCommits @@ -70,7 +68,7 @@ export const getContributionsSummary = async ( } }`; - const response = await request(query, userConfig.token); + const response = await request(query, connection); if ("error" in response) return response; return { success: true, data: response.data, platform: "github" }; }; diff --git a/services/platform/github/request.ts b/services/platform/github/request.ts index d2b9541..21ea1ae 100644 --- a/services/platform/github/request.ts +++ b/services/platform/github/request.ts @@ -1,14 +1,15 @@ +import { Connection } from "@prisma/client"; /** * @param {string} query - The GraphQL query to send to the GitHub API * @returns {Promise} - The response from the GitHub API * @throws {Error} - If the response is not a 2xx status code */ -export default function request(query: string, token: string) { +export default function request(query: string, connection: Connection) { return fetch("https://api.github.com/graphql", { method: "POST", headers: { - Authorization: `Bearer ${token}`, + Authorization: `Bearer ${connection.access_token}`, Accept: "application/vnd.github.v4.idl", "Content-Type": "application/json", }, diff --git a/services/platform/response.ts b/services/platform/response.ts index 3830b92..c3c88c9 100644 --- a/services/platform/response.ts +++ b/services/platform/response.ts @@ -1,12 +1,13 @@ import JSXRender from "@utils/render"; import { trimChars } from "@utils"; +import { Connection } from "@prisma/client"; export const getPlatformResponse = async ( query: any, services: any, templates: any, - userConfig: any | undefined, - connection: any + connection: Connection, + userConfig: any | undefined ) => { const { method, returnType } = query; if (method === undefined || typeof method !== "string") @@ -44,7 +45,7 @@ export const getPlatformResponse = async ( if (!template) return { success: false, status: 500, error: "Template not found" }; - const response = await service(userConfig, connection); + const response = await service(connection, userConfig); if (response.success === false) { return { success: false, status: 500, error: response.error }; diff --git a/services/platform/stackoverflow/index.ts b/services/platform/stackoverflow/index.ts index ae250dd..578eeac 100644 --- a/services/platform/stackoverflow/index.ts +++ b/services/platform/stackoverflow/index.ts @@ -1,6 +1,6 @@ import { ServiceResponse } from "@services/platform/types"; import request from "@services/platform/stackoverflow/request"; -import { StackoverflowUserConfig } from "@services/platform/types"; +import { Connection } from "@prisma/client"; /** * @name getReputation @@ -8,30 +8,14 @@ import { StackoverflowUserConfig } from "@services/platform/types"; * @description Get the total reputation of the user */ export const getReputation = async ( - userConfig: StackoverflowUserConfig + connection: Connection ): Promise => { - if (!userConfig.userId) - return { - success: false, - error: { - message: "User ID is missing in the user configuration", - code: 400, - }, - }; - - const response = await request( - `/users/${userConfig.userId}/reputation?site=stackoverflow` - ); + const response = await request("/2.3/me"); if ("error" in response) return response; - const reputation = response.data.items.reduce( - (acc: number, el: any) => acc + el.reputation_change, - 0 - ); - return { success: true, - data: { reputation }, + data: { reputation: 5 }, platform: "stackoverflow", }; }; diff --git a/services/platform/wakatime/index.ts b/services/platform/wakatime/index.ts index 874d2d8..292a783 100644 --- a/services/platform/wakatime/index.ts +++ b/services/platform/wakatime/index.ts @@ -9,8 +9,8 @@ import { Connection } from "@prisma/client"; * @description Get overall time since today */ export const getAllTimeSinceToday = async ( - userConfig: WakatimeUserConfig, - connection: Connection + connection: Connection, + userConfig: WakatimeUserConfig ): Promise => { const response = await request( "/users/current/all_time_since_today", diff --git a/services/prisma/schema.prisma b/services/prisma/schema.prisma index 91a659d..f46b7a5 100644 --- a/services/prisma/schema.prisma +++ b/services/prisma/schema.prisma @@ -67,11 +67,11 @@ model Connection { userId String @db.ObjectId platformId String @db.ObjectId - refresh_token String? @db.String - access_token String? @db.String - expires_at Int? + refresh_token String @db.String + access_token String @db.String + expires_at Int type String - token_type String? + token_type String scope String? user User @relation(fields: [userId], references: [id], onDelete: Cascade) From f6bbef103a731e60df3856f2c943b4f42d166835 Mon Sep 17 00:00:00 2001 From: Samet Date: Mon, 6 Mar 2023 01:10:55 +0300 Subject: [PATCH 2/7] docs: updated the .env.sample file --- .env.sample | 21 +++++++++++++++++++-- 1 file changed, 19 insertions(+), 2 deletions(-) diff --git a/.env.sample b/.env.sample index 2a4cdbb..9850fa5 100644 --- a/.env.sample +++ b/.env.sample @@ -1,6 +1,23 @@ -DATABASE_URL = xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx +DATABASE_URL = xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx +JWT_SECRET = xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx + +# The URL of the app +# It's used in the callback URL of the OAuth2 flow +# Define it as http://localhost:3000 if you're running the app locally +AUTH_URL = xxxxxxxxxxxxxxxxxxxxx + +# This client is used to login to the app GITHUB_CLIENT_ID = xxxxxxxxxxxxxxxxxxxx GITHUB_SECRET = xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx -JWT_SECRET = xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx \ No newline at end of file +# These are used to fetch users' metrics +GITHUB_CONNECTOR_ID = xxxxxxxxxxxxxxxxxxxx +GITHUB_CONNECTOR_SECRET = xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx +GITHUB_CONNECTOR_INSTALL_URL = xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx + +STACKAPPS_CLIENT_ID = xxxxx +STACKAPPS_SECRET = xxxxxxxxxxxxxxxxxxxxxxxx + +WAKATIME_CLIENT_ID = xxxxxxxxxxxxxxxxxxxxxxxx +WAKATIME_SECRET = xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx \ No newline at end of file From 0c58915a57ab10bc38a1dcb82f68a8fb039c4751 Mon Sep 17 00:00:00 2001 From: Samet Date: Mon, 6 Mar 2023 01:58:18 +0300 Subject: [PATCH 3/7] docs: updated the readme --- .gitignore | 4 +++- README.md | 68 ++++++++++++++++++++++++++++++++++++---------------- package.json | 1 - 3 files changed, 51 insertions(+), 22 deletions(-) diff --git a/.gitignore b/.gitignore index e50c531..ddfd60c 100644 --- a/.gitignore +++ b/.gitignore @@ -35,4 +35,6 @@ yarn-error.log* *.tsbuildinfo next-env.d.ts -.env \ No newline at end of file +.env +.env.production +.env.local \ No newline at end of file diff --git a/README.md b/README.md index 0af0223..ab6e226 100644 --- a/README.md +++ b/README.md @@ -1,35 +1,63 @@ ## devstats -_WIP_. +_You worked hard, it's time to manifest._ -## Supported platforms +[![Vercel](https://vercelbadge.vercel.app/api/sametcodes/devstats?style=flat-square)](https://vercelbadge.vercel.app/api/sametcodes/devstats?style=flat-square) -### GitHub +### Running locally -> TODO: mention definition of environmant variable for username and personal access token. +Make sure you install the dependencies first. -It uses GitHub GraphQL API to fetch user calendar activities. It requires to create personal access token with the following scopes: +```bash +npm install +``` + +You need the `.env.local` file that have environment variables. You can get it from the [Vercel](https://vercel.com) project settings, or just by running `vercel env pull .env.local` command if you have the access to the vercel project. If you don't have, ask admin to get it. + +Before running, you need to generate the database schema and prepare `husky` hooks. +```bash +npm run prepare ``` -repo -read:packages -read:org -read:public_key -read:repo_hook -user -read:discussion -read:enterprise -read:gpg_key + +```bash +npm run prisma:generate ``` -Create a new personal access token on [here](https://github.com/settings/tokens/new). +You are ready to go to run the development server. -### CodeWars +```bash +npm run dev +``` -> TODO: mention definition of environment variables for username +Good hacking. + +### Code linting and prettifying + +This project uses [ESLint](https://eslint.org/) and [Prettier](https://prettier.io/) for code linting and formatting. You can run the following command to lint the code and check if there are any errors. + +```bash +npm run lint +``` -### StackOverflow +If you want to fix the errors automatically, you can run the following command. + +```bash +npm run lint:fix +``` + +### Commit linting + +This project uses [Husky](https://typicode.github.io/husky/#/) to run the linting and formatting before every commit. If commits do not fit the conventional commit format, the commit will be rejected. Check the rules [here](https://www.conventionalcommits.org/en/v1.0.0/#specification). + +### Scripts + +The metric methods require name and descriptions on the database records, and they are provided on as JSDoc comments on the methods. You can run the following command to migrate metric methods to the related database records. + +```bash +npm run migrate:methods +``` -dc +## Versions and changelogs -> TODO: mention definition of environment variables for `user_id` +Please check the [releases page](https://github.com/sametcodes/devstats/releases) to see the versions and changelogs. \ No newline at end of file diff --git a/package.json b/package.json index 71eec2d..f8d4d6a 100644 --- a/package.json +++ b/package.json @@ -12,7 +12,6 @@ "prettier": "prettier --check .", "prettier:fix": "prettier --write .", "prepare": "husky install", - "jsdoc:explain": "jsdoc -X $1", "migrate:methods": "ts-node -r tsconfig-paths/register scripts/migrates/methods.ts" }, "dependencies": { From 177cd795927ca95a82629483caa6d3e6c9895298 Mon Sep 17 00:00:00 2001 From: Samet Date: Mon, 6 Mar 2023 04:49:33 +0300 Subject: [PATCH 4/7] refactor(#16): separated environments --- .env.sample | 20 +++++++------------- .gitignore | 3 +-- pages/api/auth/[...nextauth].ts | 8 ++++---- pages/api/oauth/[...route].ts | 4 ++++ services/oauth/providers/github.ts | 12 ++++++------ services/oauth/providers/stackoverflow.ts | 2 +- services/oauth/providers/wakatime.ts | 2 +- 7 files changed, 24 insertions(+), 27 deletions(-) diff --git a/.env.sample b/.env.sample index 9850fa5..4d136db 100644 --- a/.env.sample +++ b/.env.sample @@ -1,23 +1,17 @@ - DATABASE_URL = xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx JWT_SECRET = xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx -# The URL of the app # It's used in the callback URL of the OAuth2 flow # Define it as http://localhost:3000 if you're running the app locally -AUTH_URL = xxxxxxxxxxxxxxxxxxxxx - -# This client is used to login to the app -GITHUB_CLIENT_ID = xxxxxxxxxxxxxxxxxxxx -GITHUB_SECRET = xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx - -# These are used to fetch users' metrics -GITHUB_CONNECTOR_ID = xxxxxxxxxxxxxxxxxxxx -GITHUB_CONNECTOR_SECRET = xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx -GITHUB_CONNECTOR_INSTALL_URL = xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx +NEXTAUTH_URL = xxxxxxxxxxxxxxxxxxxxxxx +# OAuth Apps used to fetch users' metrics STACKAPPS_CLIENT_ID = xxxxx STACKAPPS_SECRET = xxxxxxxxxxxxxxxxxxxxxxxx WAKATIME_CLIENT_ID = xxxxxxxxxxxxxxxxxxxxxxxx -WAKATIME_SECRET = xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx \ No newline at end of file +WAKATIME_SECRET = xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx + +GITHUB_CLIENT_ID = xxxxxxxxxxxxxxxxxxxx +GITHUB_CLIENT_SECRET = xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx +GITHUB_CLIENT_INSTALL_URL = xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx \ No newline at end of file diff --git a/.gitignore b/.gitignore index ddfd60c..a741597 100644 --- a/.gitignore +++ b/.gitignore @@ -36,5 +36,4 @@ yarn-error.log* next-env.d.ts .env -.env.production -.env.local \ No newline at end of file +.env.staging \ No newline at end of file diff --git a/pages/api/auth/[...nextauth].ts b/pages/api/auth/[...nextauth].ts index ef7cd5e..93ed8a0 100644 --- a/pages/api/auth/[...nextauth].ts +++ b/pages/api/auth/[...nextauth].ts @@ -4,17 +4,17 @@ import prisma from "@services/prisma"; import callbacks from "@services/nextauth/callbacks"; import GithubProvider from "next-auth/providers/github"; -const { GITHUB_CLIENT_ID, GITHUB_SECRET } = process.env; +const { GITHUB_CLIENT_ID, GITHUB_CLIENT_SECRET } = process.env; -if (GITHUB_CLIENT_ID === undefined || GITHUB_SECRET === undefined) { - throw new Error("GITHUB_CLIENT_ID and GITHUB_SECRET must be defined"); +if (GITHUB_CLIENT_ID === undefined || GITHUB_CLIENT_SECRET === undefined) { + throw new Error("GITHUB_CLIENT_ID and GITHUB_CLIENT_SECRET must be defined"); } export const authOptions = { providers: [ GithubProvider({ clientId: GITHUB_CLIENT_ID, - clientSecret: GITHUB_SECRET, + clientSecret: GITHUB_CLIENT_SECRET, }), ], secret: process.env.JWT_SECRET, diff --git a/pages/api/oauth/[...route].ts b/pages/api/oauth/[...route].ts index b8871bd..adce828 100644 --- a/pages/api/oauth/[...route].ts +++ b/pages/api/oauth/[...route].ts @@ -41,6 +41,10 @@ export default async function handler( if (action === "connect") { const provider = providers[platform]; + if (provider.connect_url) { + return res.redirect(provider.connect_url); + } + const redirect_uri = provider.authorization.authorizeURL({ redirect_uri: provider.redirect_uri, scope: provider.scope, diff --git a/services/oauth/providers/github.ts b/services/oauth/providers/github.ts index 348cd93..24ded7d 100644 --- a/services/oauth/providers/github.ts +++ b/services/oauth/providers/github.ts @@ -4,13 +4,13 @@ import { Provider } from "@services/oauth"; const config: Provider = { code: "github", client: { - id: process.env.GITHUB_CONNECTOR_ID as string, - secret: process.env.GITHUB_CONNECTOR_SECRET as string, + id: process.env.GITHUB_CLIENT_ID as string, + secret: process.env.GITHUB_CLIENT_SECRET as string, }, authorization: new AuthorizationCode({ client: { - id: process.env.GITHUB_CONNECTOR_ID as string, - secret: process.env.GITHUB_CONNECTOR_SECRET as string, + id: process.env.GITHUB_CLIENT_ID as string, + secret: process.env.GITHUB_CLIENT_SECRET as string, }, auth: { tokenHost: "https://github.com", @@ -45,8 +45,8 @@ const config: Provider = { console.error(`Error getting profile from github: ${err.message}`); } }, - connect_url: process.env.GITHUB_CONNECTOR_INSTALL_URL, - redirect_uri: `${process.env.AUTH_URL}/api/oauth/callback/github`, + connect_url: process.env.GITHUB_CLIENT_INSTALL_URL, + redirect_uri: `${process.env.NEXTAUTH_URL}/api/oauth/callback/github`, scope: "read:user user:email repo", }; diff --git a/services/oauth/providers/stackoverflow.ts b/services/oauth/providers/stackoverflow.ts index a18cf31..7d6134d 100644 --- a/services/oauth/providers/stackoverflow.ts +++ b/services/oauth/providers/stackoverflow.ts @@ -33,7 +33,7 @@ const config: Provider = { image: "", }); }, - redirect_uri: `${process.env.AUTH_URL}/api/oauth/callback/stackoverflow`, + redirect_uri: `${process.env.NEXTAUTH_URL}/api/oauth/callback/stackoverflow`, scope: "private_info", }; diff --git a/services/oauth/providers/wakatime.ts b/services/oauth/providers/wakatime.ts index 358cf28..f00d127 100644 --- a/services/oauth/providers/wakatime.ts +++ b/services/oauth/providers/wakatime.ts @@ -46,7 +46,7 @@ const config: Provider = { console.error(`Error getting profile from wakatime: ${err.message}`); } }, - redirect_uri: `${process.env.AUTH_URL}/api/oauth/callback/wakatime`, + redirect_uri: `${process.env.NEXTAUTH_URL}/api/oauth/callback/wakatime`, scope: "email,read_stats,read_logged_time", }; From 817a4ea3ad39a019548646406272cacaf5bc5355 Mon Sep 17 00:00:00 2001 From: Samet Date: Mon, 6 Mar 2023 04:51:15 +0300 Subject: [PATCH 5/7] chore(#16): created script to build for staging --- README.md | 2 +- package-lock.json | 42 ++++++++++++++++++++++++++++++++++++++++++ package.json | 1 + 3 files changed, 44 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index ab6e226..29a99c3 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,7 @@ Make sure you install the dependencies first. npm install ``` -You need the `.env.local` file that have environment variables. You can get it from the [Vercel](https://vercel.com) project settings, or just by running `vercel env pull .env.local` command if you have the access to the vercel project. If you don't have, ask admin to get it. +You need the `.env` file that have environment variables. You can get it from the [Vercel](https://vercel.com) project settings, or just by running `vercel env pull .env` command if you have the access to the vercel project. If you don't have, ask admin to get it. Before running, you need to generate the database schema and prepare `husky` hooks. diff --git a/package-lock.json b/package-lock.json index 36daa8f..27959c3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -24,6 +24,7 @@ "@types/react-dom": "18.0.8", "@types/simple-oauth2": "^5.0.2", "chalk": "^4.1.0", + "env-cmd": "^10.1.0", "eslint": "8.34.0", "eslint-config-next": "13.2.0", "husky": "^8.0.0", @@ -1649,6 +1650,15 @@ "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", "dev": true }, + "node_modules/commander": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz", + "integrity": "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==", + "dev": true, + "engines": { + "node": ">= 6" + } + }, "node_modules/compare-func": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/compare-func/-/compare-func-2.0.0.tgz", @@ -1974,6 +1984,22 @@ "url": "https://github.com/fb55/entities?sponsor=1" } }, + "node_modules/env-cmd": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/env-cmd/-/env-cmd-10.1.0.tgz", + "integrity": "sha512-mMdWTT9XKN7yNth/6N6g2GuKuJTsKMDHlQFUDacb/heQRRWOTIZ42t1rMHnQu4jYxU1ajdTeJM+9eEETlqToMA==", + "dev": true, + "dependencies": { + "commander": "^4.0.0", + "cross-spawn": "^7.0.0" + }, + "bin": { + "env-cmd": "bin/env-cmd.js" + }, + "engines": { + "node": ">=8.0.0" + } + }, "node_modules/error-ex": { "version": "1.3.2", "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.2.tgz", @@ -7225,6 +7251,12 @@ "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", "dev": true }, + "commander": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz", + "integrity": "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==", + "dev": true + }, "compare-func": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/compare-func/-/compare-func-2.0.0.tgz", @@ -7469,6 +7501,16 @@ "integrity": "sha512-hCx1oky9PFrJ611mf0ifBLBRW8lUUVRlFolb5gWRfIELabBlbp9xZvrqZLZAs+NxFnbfQoeGd8wDkygjg7U85w==", "dev": true }, + "env-cmd": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/env-cmd/-/env-cmd-10.1.0.tgz", + "integrity": "sha512-mMdWTT9XKN7yNth/6N6g2GuKuJTsKMDHlQFUDacb/heQRRWOTIZ42t1rMHnQu4jYxU1ajdTeJM+9eEETlqToMA==", + "dev": true, + "requires": { + "commander": "^4.0.0", + "cross-spawn": "^7.0.0" + } + }, "error-ex": { "version": "1.3.2", "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.2.tgz", diff --git a/package.json b/package.json index f8d4d6a..63ce896 100644 --- a/package.json +++ b/package.json @@ -5,6 +5,7 @@ "scripts": { "dev": "next dev", "build": "npm run prisma:generate && next build", + "build:staging": "env-cmd -f .env.staging npm run build && env-cmd -f .env.staging npm run start", "start": "next start", "prisma:generate": "prisma generate --schema=./services/prisma/schema.prisma", "lint": "next lint --max-warnings=0", From ecdd2210cca0f946f90a8208efa7e546f7b1eaba Mon Sep 17 00:00:00 2001 From: Samet Date: Mon, 6 Mar 2023 04:52:16 +0300 Subject: [PATCH 6/7] chore(#16): creating a script to migrate the platforms --- README.md | 2 +- package.json | 5 +++-- scripts/migrates/{methods.ts => platform.ts} | 13 ++++++++++--- 3 files changed, 14 insertions(+), 6 deletions(-) rename scripts/migrates/{methods.ts => platform.ts} (89%) diff --git a/README.md b/README.md index 29a99c3..a95d942 100644 --- a/README.md +++ b/README.md @@ -55,7 +55,7 @@ This project uses [Husky](https://typicode.github.io/husky/#/) to run the lintin The metric methods require name and descriptions on the database records, and they are provided on as JSDoc comments on the methods. You can run the following command to migrate metric methods to the related database records. ```bash -npm run migrate:methods +npm run migrate:platform ``` ## Versions and changelogs diff --git a/package.json b/package.json index 63ce896..e9428bb 100644 --- a/package.json +++ b/package.json @@ -7,13 +7,13 @@ "build": "npm run prisma:generate && next build", "build:staging": "env-cmd -f .env.staging npm run build && env-cmd -f .env.staging npm run start", "start": "next start", - "prisma:generate": "prisma generate --schema=./services/prisma/schema.prisma", "lint": "next lint --max-warnings=0", "lint:fix": "next lint --fix", "prettier": "prettier --check .", "prettier:fix": "prettier --write .", "prepare": "husky install", - "migrate:methods": "ts-node -r tsconfig-paths/register scripts/migrates/methods.ts" + "prisma:generate": "prisma generate --schema=./services/prisma/schema.prisma", + "migrate:platform": "ts-node -r tsconfig-paths/register scripts/migrates/platform.ts" }, "dependencies": { "@next-auth/prisma-adapter": "^1.0.5", @@ -32,6 +32,7 @@ "@types/react-dom": "18.0.8", "@types/simple-oauth2": "^5.0.2", "chalk": "^4.1.0", + "env-cmd": "^10.1.0", "eslint": "8.34.0", "eslint-config-next": "13.2.0", "husky": "^8.0.0", diff --git a/scripts/migrates/methods.ts b/scripts/migrates/platform.ts similarity index 89% rename from scripts/migrates/methods.ts rename to scripts/migrates/platform.ts index 0cfb3ab..928c8f1 100644 --- a/scripts/migrates/methods.ts +++ b/scripts/migrates/platform.ts @@ -60,9 +60,16 @@ const migrate = async ({ docs: JSDocMinified[]; code: string; }): Promise => { - const platform = await prisma.platform.findUnique({ where: { code } }); - - if (!platform) return console.error(`Platform ${code} not found`); + let platform = await prisma.platform.findUnique({ where: { code } }); + + if (!platform) { + const name = Array.from(code) + .map((letter, i) => (i === 0 ? letter.toUpperCase() : letter)) + .join(""); + platform = await prisma.platform.create({ + data: { code, name, methods: [] }, + }); + } const methods = docs .map((doc) => ({ From 960cfc0c6c216cea62f8625161403c81c35b6e67 Mon Sep 17 00:00:00 2001 From: Samet Date: Mon, 6 Mar 2023 05:15:30 +0300 Subject: [PATCH 7/7] fix(#16): fix problems with callback URL on github --- pages/api/auth/[...nextauth].ts | 3 ++ services/prisma/schema.prisma | 87 +++++++++++++++++---------------- 2 files changed, 47 insertions(+), 43 deletions(-) diff --git a/pages/api/auth/[...nextauth].ts b/pages/api/auth/[...nextauth].ts index 93ed8a0..15fa25e 100644 --- a/pages/api/auth/[...nextauth].ts +++ b/pages/api/auth/[...nextauth].ts @@ -15,6 +15,9 @@ export const authOptions = { GithubProvider({ clientId: GITHUB_CLIENT_ID, clientSecret: GITHUB_CLIENT_SECRET, + client: { + redirect_uris: [`${process.env.NEXTAUTH_URL}/api/auth/callback/github`], + }, }), ], secret: process.env.JWT_SECRET, diff --git a/services/prisma/schema.prisma b/services/prisma/schema.prisma index f46b7a5..27390fe 100644 --- a/services/prisma/schema.prisma +++ b/services/prisma/schema.prisma @@ -11,13 +11,13 @@ generator client { } model Platform { - id String @id @default(auto()) @map("_id") @db.ObjectId + id String @id @default(auto()) @map("_id") @db.ObjectId name String - code String @unique + code String @unique raw_configuration Json? methods Json - config Config[] + config Config[] } model User { @@ -28,11 +28,11 @@ model User { emailVerified DateTime? image String? - accounts Account[] - sessions Session[] - config Config[] - connections Connection[] - profiles ConnectionProfile[] + accounts Account[] + sessions Session[] + config Config[] + connections Connection[] + profiles ConnectionProfile[] } model Config { @@ -45,51 +45,52 @@ model Config { } model Account { - id String @id @default(auto()) @map("_id") @db.ObjectId - userId String @db.ObjectId - type String - provider String - providerAccountId String - refresh_token String? @db.String - access_token String? @db.String - expires_at Int? - token_type String? - scope String? - id_token String? @db.String - session_state String? - user User @relation(fields: [userId], references: [id], onDelete: Cascade) + id String @id @default(auto()) @map("_id") @db.ObjectId + userId String @db.ObjectId + type String + provider String + providerAccountId String + refresh_token String? @db.String + access_token String? @db.String + expires_at Int? + token_type String? + scope String? + id_token String? @db.String + session_state String? + refresh_token_expires_in Int? + user User @relation(fields: [userId], references: [id], onDelete: Cascade) @@unique([provider, providerAccountId]) } model Connection { - id String @id @default(auto()) @map("_id") @db.ObjectId - userId String @db.ObjectId - platformId String @db.ObjectId - - refresh_token String @db.String - access_token String @db.String - expires_at Int - type String - token_type String - scope String? - - user User @relation(fields: [userId], references: [id], onDelete: Cascade) - profile ConnectionProfile? + id String @id @default(auto()) @map("_id") @db.ObjectId + userId String @db.ObjectId + platformId String @db.ObjectId + + refresh_token String @db.String + access_token String @db.String + expires_at Int + type String + token_type String + scope String? + + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + profile ConnectionProfile? } model ConnectionProfile { - id String @id @default(auto()) @map("_id") @db.ObjectId - userId String @db.ObjectId - platformId String @db.ObjectId - connectionId String @db.ObjectId @unique + id String @id @default(auto()) @map("_id") @db.ObjectId + userId String @db.ObjectId + platformId String @db.ObjectId + connectionId String @unique @db.ObjectId - name String - email String - image String + name String + email String + image String - user User @relation(fields: [userId], references: [id], onDelete: Cascade) - connection Connection @relation(fields: [connectionId], references: [id], onDelete: Cascade) + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + connection Connection @relation(fields: [connectionId], references: [id], onDelete: Cascade) } model Session {