mirror of
https://github.com/ajayyy/SponsorBlockServer.git
synced 2025-12-06 19:47:00 +03:00
Add access token system
This commit is contained in:
18
databases/_upgrade_private_11.sql
Normal file
18
databases/_upgrade_private_11.sql
Normal 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
1
package-lock.json
generated
@@ -15,6 +15,7 @@
|
|||||||
"express": "^4.18.1",
|
"express": "^4.18.1",
|
||||||
"express-promise-router": "^4.1.1",
|
"express-promise-router": "^4.1.1",
|
||||||
"express-rate-limit": "^6.4.0",
|
"express-rate-limit": "^6.4.0",
|
||||||
|
"form-data": "^4.0.0",
|
||||||
"lodash": "^4.17.21",
|
"lodash": "^4.17.21",
|
||||||
"pg": "^8.7.3",
|
"pg": "^8.7.3",
|
||||||
"rate-limit-redis": "^3.0.1",
|
"rate-limit-redis": "^3.0.1",
|
||||||
|
|||||||
@@ -24,6 +24,7 @@
|
|||||||
"express": "^4.18.1",
|
"express": "^4.18.1",
|
||||||
"express-promise-router": "^4.1.1",
|
"express-promise-router": "^4.1.1",
|
||||||
"express-rate-limit": "^6.4.0",
|
"express-rate-limit": "^6.4.0",
|
||||||
|
"form-data": "^4.0.0",
|
||||||
"lodash": "^4.17.21",
|
"lodash": "^4.17.21",
|
||||||
"pg": "^8.7.3",
|
"pg": "^8.7.3",
|
||||||
"rate-limit-redis": "^3.0.1",
|
"rate-limit-redis": "^3.0.1",
|
||||||
|
|||||||
@@ -46,6 +46,8 @@ import { getChapterNames } from "./routes/getChapterNames";
|
|||||||
import { getTopCategoryUsers } from "./routes/getTopCategoryUsers";
|
import { getTopCategoryUsers } from "./routes/getTopCategoryUsers";
|
||||||
import { addUserAsTempVIP } from "./routes/addUserAsTempVIP";
|
import { addUserAsTempVIP } from "./routes/addUserAsTempVIP";
|
||||||
import { addFeature } from "./routes/addFeature";
|
import { addFeature } from "./routes/addFeature";
|
||||||
|
import { generateTokenRequest } from "./routes/generateToken";
|
||||||
|
import { verifyTokenRequest } from "./routes/verifyToken";
|
||||||
|
|
||||||
export function createServer(callback: () => void): Server {
|
export function createServer(callback: () => void): Server {
|
||||||
// Create a service (the app object is just a callback).
|
// Create a service (the app object is just a callback).
|
||||||
@@ -194,6 +196,9 @@ function setupRoutes(router: Router) {
|
|||||||
|
|
||||||
router.post("/api/feature", addFeature);
|
router.post("/api/feature", addFeature);
|
||||||
|
|
||||||
|
router.get("/api/generateToken/:type", generateTokenRequest);
|
||||||
|
router.get("/api/verifyToken/", verifyTokenRequest);
|
||||||
|
|
||||||
if (config.postgres?.enabled) {
|
if (config.postgres?.enabled) {
|
||||||
router.get("/database", (req, res) => dumpDatabase(req, res, true));
|
router.get("/database", (req, res) => dumpDatabase(req, res, true));
|
||||||
router.get("/database.json", (req, res) => dumpDatabase(req, res, false));
|
router.get("/database.json", (req, res) => dumpDatabase(req, res, false));
|
||||||
|
|||||||
@@ -135,6 +135,15 @@ addDefaults(config, {
|
|||||||
disableOfflineQueue: true,
|
disableOfflineQueue: true,
|
||||||
expiryTime: 24 * 60 * 60,
|
expiryTime: 24 * 60 * 60,
|
||||||
getTimeout: 40
|
getTimeout: 40
|
||||||
|
},
|
||||||
|
patreon: {
|
||||||
|
clientId: "",
|
||||||
|
clientSecret: "",
|
||||||
|
minPrice: 0,
|
||||||
|
redirectUri: "https://sponsor.ajay.app/api/generateToken/patreon"
|
||||||
|
},
|
||||||
|
gumroad: {
|
||||||
|
productPermalinks: []
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
loadFromEnv(config);
|
loadFromEnv(config);
|
||||||
|
|||||||
48
src/routes/generateToken.ts
Normal file
48
src/routes/generateToken.ts
Normal 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>
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
81
src/routes/verifyToken.ts
Normal file
81
src/routes/verifyToken.ts
Normal 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;
|
||||||
|
}
|
||||||
@@ -65,6 +65,15 @@ export interface SBSConfig {
|
|||||||
dumpDatabase?: DumpDatabase;
|
dumpDatabase?: DumpDatabase;
|
||||||
diskCacheURL: string;
|
diskCacheURL: string;
|
||||||
crons: CronJobOptions;
|
crons: CronJobOptions;
|
||||||
|
patreon: {
|
||||||
|
clientId: string,
|
||||||
|
clientSecret: string,
|
||||||
|
minPrice: number,
|
||||||
|
redirectUri: string
|
||||||
|
}
|
||||||
|
gumroad: {
|
||||||
|
productPermalinks: string[],
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface WebhookConfig {
|
export interface WebhookConfig {
|
||||||
|
|||||||
144
src/utils/tokenUtils.ts
Normal file
144
src/utils/tokenUtils.ts
Normal 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;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user