Merge pull request #501 from ajayyy/chapter-auth

Chapter auth
This commit is contained in:
Ajay Ramachandran 2022-09-02 02:06:45 -04:00 committed by GitHub
commit 9f2d13780c
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
10 changed files with 327 additions and 2 deletions

View file

@ -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;

1
package-lock.json generated
View file

@ -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",

View file

@ -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",

View file

@ -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));

View file

@ -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);

View file

@ -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<Response> {
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(`
<h1>
Your access key:
</h1>
<p>
<b>
${licenseKey}
</b>
</p>
<p>
Copy this into the textbox in the other tab
</p>
`);
} else {
return res.status(401).send(`
<h1>
Failed to generate an access key
</h1>
`);
}
}
}

View file

@ -8,6 +8,7 @@ import { getReputation } from "../utils/reputation";
import { Category, SegmentUUID } from "../types/segments.model";
import { config } from "../config";
import { canSubmit } from "../utils/permissions";
import { oneOf } from "../utils/promise";
const maxRewardTime = config.maxRewardTimePerSegmentInSeconds;
async function dbGetSubmittedSegmentSummary(userID: HashedUserID): Promise<{ minutesSaved: number, segmentCount: number }> {
@ -115,6 +116,13 @@ async function getPermissions(userID: HashedUserID): Promise<Record<string, bool
return result;
}
async function getFreeChaptersAccess(userID: HashedUserID): Promise<boolean> {
return await oneOf([isUserVIP(userID),
(async () => (await getReputation(userID)) > 0)(),
(async () => !!(await db.prepare("get", `SELECT "timeSubmitted" FROM "sponsorTimes" WHERE "timeSubmitted" < 1590969600000 AND "userID" = ? LIMIT 1`, [userID], { useReplica: true })))()
]);
}
type cases = Record<string, any>
const executeIfFunction = (f: any) =>
@ -139,7 +147,8 @@ const dbGetValue = (userID: HashedUserID, property: string): Promise<string|Segm
reputation: () => getReputation(userID),
vip: () => isUserVIP(userID),
lastSegmentID: () => dbGetLastSegmentForUser(userID),
permissions: () => getPermissions(userID)
permissions: () => getPermissions(userID),
freeChaptersAccess: () => getFreeChaptersAccess(userID)
})("")(property);
};
@ -149,7 +158,7 @@ async function getUserInfo(req: Request, res: Response): Promise<Response> {
const defaultProperties: string[] = ["userID", "userName", "minutesSaved", "segmentCount", "ignoredSegmentCount",
"viewCount", "ignoredViewCount", "warnings", "warningReason", "reputation",
"vip", "lastSegmentID"];
const allProperties: string[] = [...defaultProperties, "banned", "permissions"];
const allProperties: string[] = [...defaultProperties, "banned", "permissions", "freeChaptersAccess"];
let paramValues: string[] = req.query.values
? JSON.parse(req.query.values as string)
: req.query.value

81
src/routes/verifyToken.ts Normal file
View file

@ -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<Response> {
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<boolean> {
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;
}

View file

@ -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 {

144
src/utils/tokenUtils.ts Normal file
View file

@ -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<string> {
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<boolean> {
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<PatreonIdentityData> {
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;
}