From 7060c0ab0dd74dd2bbea0fa7c695dac5ef83a44b Mon Sep 17 00:00:00 2001 From: Ajay Date: Wed, 31 Aug 2022 01:55:38 -0400 Subject: [PATCH] Add access token system --- databases/_upgrade_private_11.sql | 18 ++++ package-lock.json | 1 + package.json | 1 + src/app.ts | 5 ++ src/config.ts | 9 ++ src/routes/generateToken.ts | 48 ++++++++++ src/routes/verifyToken.ts | 81 +++++++++++++++++ src/types/config.model.ts | 9 ++ src/utils/tokenUtils.ts | 144 ++++++++++++++++++++++++++++++ 9 files changed, 316 insertions(+) create mode 100644 databases/_upgrade_private_11.sql create mode 100644 src/routes/generateToken.ts create mode 100644 src/routes/verifyToken.ts create mode 100644 src/utils/tokenUtils.ts diff --git a/databases/_upgrade_private_11.sql b/databases/_upgrade_private_11.sql new file mode 100644 index 0000000..3dc80bc --- /dev/null +++ b/databases/_upgrade_private_11.sql @@ -0,0 +1,18 @@ +BEGIN TRANSACTION; + +CREATE TABLE IF NOT EXISTS "licenseKeys" ( + "licenseKey" TEXT NOT NULL PRIMARY KEY, + "time" INTEGER NOT NULL, + "type" TEXT NOT NULL +); + +CREATE TABLE IF NOT EXISTS "oauthLicenseKeys" ( + "licenseKey" TEXT NOT NULL PRIMARY KEY, + "accessToken" TEXT NOT NULL, + "refreshToken" TEXT NOT NULL, + "expiresIn" INTEGER NOT NULL +); + +UPDATE "config" SET value = 11 WHERE key = 'version'; + +COMMIT; \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index 7b9530b..00e507e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,6 +15,7 @@ "express": "^4.18.1", "express-promise-router": "^4.1.1", "express-rate-limit": "^6.4.0", + "form-data": "^4.0.0", "lodash": "^4.17.21", "pg": "^8.7.3", "rate-limit-redis": "^3.0.1", diff --git a/package.json b/package.json index 0f92f7c..29b7baa 100644 --- a/package.json +++ b/package.json @@ -24,6 +24,7 @@ "express": "^4.18.1", "express-promise-router": "^4.1.1", "express-rate-limit": "^6.4.0", + "form-data": "^4.0.0", "lodash": "^4.17.21", "pg": "^8.7.3", "rate-limit-redis": "^3.0.1", diff --git a/src/app.ts b/src/app.ts index 92a94bf..6a0c7c8 100644 --- a/src/app.ts +++ b/src/app.ts @@ -46,6 +46,8 @@ import { getChapterNames } from "./routes/getChapterNames"; import { getTopCategoryUsers } from "./routes/getTopCategoryUsers"; import { addUserAsTempVIP } from "./routes/addUserAsTempVIP"; import { addFeature } from "./routes/addFeature"; +import { generateTokenRequest } from "./routes/generateToken"; +import { verifyTokenRequest } from "./routes/verifyToken"; export function createServer(callback: () => void): Server { // Create a service (the app object is just a callback). @@ -194,6 +196,9 @@ function setupRoutes(router: Router) { router.post("/api/feature", addFeature); + router.get("/api/generateToken/:type", generateTokenRequest); + router.get("/api/verifyToken/", verifyTokenRequest); + if (config.postgres?.enabled) { router.get("/database", (req, res) => dumpDatabase(req, res, true)); router.get("/database.json", (req, res) => dumpDatabase(req, res, false)); diff --git a/src/config.ts b/src/config.ts index 352dee0..e07b5ca 100644 --- a/src/config.ts +++ b/src/config.ts @@ -135,6 +135,15 @@ addDefaults(config, { disableOfflineQueue: true, expiryTime: 24 * 60 * 60, getTimeout: 40 + }, + patreon: { + clientId: "", + clientSecret: "", + minPrice: 0, + redirectUri: "https://sponsor.ajay.app/api/generateToken/patreon" + }, + gumroad: { + productPermalinks: [] } }); loadFromEnv(config); diff --git a/src/routes/generateToken.ts b/src/routes/generateToken.ts new file mode 100644 index 0000000..ad33472 --- /dev/null +++ b/src/routes/generateToken.ts @@ -0,0 +1,48 @@ +import { Request, Response } from "express"; +import { config } from "../config"; +import { createAndSaveToken, TokenType } from "../utils/tokenUtils"; + + +interface GenerateTokenRequest extends Request { + query: { + code: string; + adminUserID?: string; + }, + params: { + type: TokenType; + } +} + +export async function generateTokenRequest(req: GenerateTokenRequest, res: Response): Promise { + const { query: { code, adminUserID }, params: { type } } = req; + + if (!code || !type) { + return res.status(400).send("Invalid request"); + } + + if (type === TokenType.patreon || (type === TokenType.local && adminUserID === config.adminUserID)) { + const licenseKey = await createAndSaveToken(type, code); + + if (licenseKey) { + return res.status(200).send(` +

+ Your access key: +

+

+ + ${licenseKey} + +

+

+ Copy this into the textbox in the other tab +

+ `); + } else { + return res.status(401).send(` +

+ Failed to generate an access key +

+ `); + } + } +} \ No newline at end of file diff --git a/src/routes/verifyToken.ts b/src/routes/verifyToken.ts new file mode 100644 index 0000000..9258f9d --- /dev/null +++ b/src/routes/verifyToken.ts @@ -0,0 +1,81 @@ +import axios from "axios"; +import { Request, Response } from "express"; +import { config } from "../config"; +import { privateDB } from "../databases/databases"; +import { Logger } from "../utils/logger"; +import { getPatreonIdentity, PatronStatus, refreshToken, TokenType } from "../utils/tokenUtils"; +import FormData from "form-data"; + +interface VerifyTokenRequest extends Request { + query: { + licenseKey: string; + } +} + +export async function verifyTokenRequest(req: VerifyTokenRequest, res: Response): Promise { + const { query: { licenseKey } } = req; + + if (!licenseKey) { + return res.status(400).send("Invalid request"); + } + + const tokens = (await privateDB.prepare("get", `SELECT "accessToken", "refreshToken", "expiresIn" from "oauthLicenseKeys" WHERE "licenseKey" = ?` + , [licenseKey])) as {accessToken: string, refreshToken: string, expiresIn: number}; + if (tokens) { + const identity = await getPatreonIdentity(tokens.accessToken); + + if (tokens.expiresIn < 15 * 24 * 60 * 60) { + refreshToken(TokenType.patreon, licenseKey, tokens.refreshToken); + } + + if (identity) { + const membership = identity.included?.[0]?.attributes; + const allowed = !!membership && ((membership.patron_status === PatronStatus.active && membership.currently_entitled_amount_cents > 0) + || (membership.patron_status === PatronStatus.former && membership.campaign_lifetime_support_cents > 300)); + + return res.status(200).send({ + allowed + }); + } else { + return res.status(500); + } + } else { + // Check Local + const result = await privateDB.prepare("get", `SELECT "licenseKey" from "licenseKeys" WHERE "licenseKey" = ?`, [licenseKey]); + if (result) { + return res.status(200).send({ + allowed: true + }); + } else { + // Gumroad + return res.status(200).send({ + allowed: await checkAllGumroadProducts(licenseKey) + }); + } + + } +} + +async function checkAllGumroadProducts(licenseKey: string): Promise { + for (const link of config.gumroad.productPermalinks) { + try { + const formData = new FormData(); + formData.append("product_permalink", link); + formData.append("license_key", licenseKey); + + const result = await axios.request({ + url: "https://api.gumroad.com/v2/licenses/verify", + data: formData, + method: "POST", + headers: formData.getHeaders() + }); + + const allowed = result.status === 200 && result.data?.success; + if (allowed) return allowed; + } catch (e) { + Logger.error(`Gumroad fetch for ${link} failed: ${e}`); + } + } + + return false; +} \ No newline at end of file diff --git a/src/types/config.model.ts b/src/types/config.model.ts index 1fb6d42..9a72713 100644 --- a/src/types/config.model.ts +++ b/src/types/config.model.ts @@ -65,6 +65,15 @@ export interface SBSConfig { dumpDatabase?: DumpDatabase; diskCacheURL: string; crons: CronJobOptions; + patreon: { + clientId: string, + clientSecret: string, + minPrice: number, + redirectUri: string + } + gumroad: { + productPermalinks: string[], + } } export interface WebhookConfig { diff --git a/src/utils/tokenUtils.ts b/src/utils/tokenUtils.ts new file mode 100644 index 0000000..7c5ad3c --- /dev/null +++ b/src/utils/tokenUtils.ts @@ -0,0 +1,144 @@ +import axios from "axios"; +import { config } from "../config"; +import { privateDB } from "../databases/databases"; +import { Logger } from "./logger"; +import FormData from "form-data"; +import { randomInt } from "node:crypto"; + +export enum TokenType { + patreon = "patreon", + local = "local", + gumroad = "gumroad" +} + +export enum PatronStatus { + active = "active_patron", + declined = "declined_patron", + former = "former_patron", +} + +export interface PatreonIdentityData { + included: Array<{ + attributes: { + currently_entitled_amount_cents: number, + campaign_lifetime_support_cents: number, + pledge_relationship_start: number, + patron_status: PatronStatus, + } + }> +} + +export async function createAndSaveToken(type: TokenType, code?: string): Promise { + switch(type) { + case TokenType.patreon: { + const domain = "https://www.patreon.com"; + try { + const formData = new FormData(); + formData.append("code", code); + formData.append("client_id", config.patreon.clientId); + formData.append("client_secret", config.patreon.clientSecret); + formData.append("grant_type", "authorization_code"); + formData.append("redirect_uri", config.patreon.redirectUri); + + const result = await axios.request({ + url: `${domain}/api/oauth2/token`, + data: formData, + method: "POST", + headers: formData.getHeaders() + }); + + if (result.status === 200) { + const licenseKey = generateToken(); + const time = Date.now(); + + await privateDB.prepare("run", `INSERT INTO "licenseKeys"("licenseKey", "time", "type") VALUES(?, ?, ?)`, [licenseKey, time, type]); + await privateDB.prepare("run", `INSERT INTO "oauthLicenseKeys"("licenseKey", "accessToken", "refreshToken", "expiresIn") VALUES(?, ?, ?, ?)` + , [licenseKey, result.data.access_token, result.data.refresh_token, result.data.expires_in]); + + + return licenseKey; + } + } catch (e) { + Logger.error(`token creation: ${e}`); + return null; + } + + break; + } + case TokenType.local: { + const licenseKey = generateToken(); + const time = Date.now(); + + await privateDB.prepare("run", `INSERT INTO "licenseKeys"("licenseKey", "time", "type") VALUES(?, ?, ?)`, [licenseKey, time, type]); + + return licenseKey; + } + } + + return null; +} + +export async function refreshToken(type: TokenType, licenseKey: string, refreshToken: string): Promise { + switch(type) { + case TokenType.patreon: { + try { + const formData = new FormData(); + formData.append("refreshToken", refreshToken); + formData.append("client_id", config.patreon.clientId); + formData.append("client_secret", config.patreon.clientSecret); + formData.append("grant_type", "refresh_token"); + + const domain = "https://www.patreon.com"; + const result = await axios.request({ + url: `${domain}/api/oauth2/token`, + data: formData, + method: "POST", + headers: formData.getHeaders() + }); + + if (result.status === 200) { + await privateDB.prepare("run", `UPDATE "oauthLicenseKeys" SET "accessToken" = ?, "refreshToken" = ?, "expiresIn" = ? WHERE "licenseKey" = ?` + , [result.data.access_token, result.data.refresh_token, result.data.expires_in, licenseKey]); + + return true; + } + } catch (e) { + Logger.error(`token refresh: ${e}`); + return false; + } + + break; + } + } + + return false; +} + +function generateToken(length = 40): string { + const charset = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"; + let result = ""; + + for (let i = 0; i < length; i++) { + result += charset[randomInt(charset.length)]; + } + + return result; +} + +export async function getPatreonIdentity(accessToken: string): Promise { + try { + const identityRequest = await axios.get(`https://www.patreon.com/api/oauth2/v2/identity?include=memberships&fields%5Bmember%5D=patron_status,currently_entitled_amount_cents,campaign_lifetime_support_cents,pledge_relationship_start`, { + headers: { + Authorization: `Bearer ${accessToken}` + } + }); + + if (identityRequest.status === 200) { + return identityRequest.data; + } + } catch (e) { + Logger.error(`identity request: ${e}`); + } + + return null; +} \ No newline at end of file