Skip to content

Commit 9f2d137

Browse files
authored
Merge pull request #501 from ajayyy/chapter-auth
Chapter auth
2 parents ab6fcb8 + acec7e5 commit 9f2d137

File tree

10 files changed

+327
-2
lines changed

10 files changed

+327
-2
lines changed

databases/_upgrade_private_11.sql

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
BEGIN TRANSACTION;
2+
3+
CREATE TABLE IF NOT EXISTS "licenseKeys" (
4+
"licenseKey" TEXT NOT NULL PRIMARY KEY,
5+
"time" INTEGER NOT NULL,
6+
"type" TEXT NOT NULL
7+
);
8+
9+
CREATE TABLE IF NOT EXISTS "oauthLicenseKeys" (
10+
"licenseKey" TEXT NOT NULL PRIMARY KEY,
11+
"accessToken" TEXT NOT NULL,
12+
"refreshToken" TEXT NOT NULL,
13+
"expiresIn" INTEGER NOT NULL
14+
);
15+
16+
UPDATE "config" SET value = 11 WHERE key = 'version';
17+
18+
COMMIT;

package-lock.json

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424
"express": "^4.18.1",
2525
"express-promise-router": "^4.1.1",
2626
"express-rate-limit": "^6.4.0",
27+
"form-data": "^4.0.0",
2728
"lodash": "^4.17.21",
2829
"pg": "^8.7.3",
2930
"rate-limit-redis": "^3.0.1",

src/app.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,8 @@ import { getChapterNames } from "./routes/getChapterNames";
4646
import { getTopCategoryUsers } from "./routes/getTopCategoryUsers";
4747
import { addUserAsTempVIP } from "./routes/addUserAsTempVIP";
4848
import { addFeature } from "./routes/addFeature";
49+
import { generateTokenRequest } from "./routes/generateToken";
50+
import { verifyTokenRequest } from "./routes/verifyToken";
4951

5052
export function createServer(callback: () => void): Server {
5153
// Create a service (the app object is just a callback).
@@ -194,6 +196,9 @@ function setupRoutes(router: Router) {
194196

195197
router.post("/api/feature", addFeature);
196198

199+
router.get("/api/generateToken/:type", generateTokenRequest);
200+
router.get("/api/verifyToken", verifyTokenRequest);
201+
197202
if (config.postgres?.enabled) {
198203
router.get("/database", (req, res) => dumpDatabase(req, res, true));
199204
router.get("/database.json", (req, res) => dumpDatabase(req, res, false));

src/config.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -135,6 +135,15 @@ addDefaults(config, {
135135
disableOfflineQueue: true,
136136
expiryTime: 24 * 60 * 60,
137137
getTimeout: 40
138+
},
139+
patreon: {
140+
clientId: "",
141+
clientSecret: "",
142+
minPrice: 0,
143+
redirectUri: "https://sponsor.ajay.app/api/generateToken/patreon"
144+
},
145+
gumroad: {
146+
productPermalinks: []
138147
}
139148
});
140149
loadFromEnv(config);

src/routes/generateToken.ts

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
import { Request, Response } from "express";
2+
import { config } from "../config";
3+
import { createAndSaveToken, TokenType } from "../utils/tokenUtils";
4+
5+
6+
interface GenerateTokenRequest extends Request {
7+
query: {
8+
code: string;
9+
adminUserID?: string;
10+
},
11+
params: {
12+
type: TokenType;
13+
}
14+
}
15+
16+
export async function generateTokenRequest(req: GenerateTokenRequest, res: Response): Promise<Response> {
17+
const { query: { code, adminUserID }, params: { type } } = req;
18+
19+
if (!code || !type) {
20+
return res.status(400).send("Invalid request");
21+
}
22+
23+
if (type === TokenType.patreon || (type === TokenType.local && adminUserID === config.adminUserID)) {
24+
const licenseKey = await createAndSaveToken(type, code);
25+
26+
if (licenseKey) {
27+
return res.status(200).send(`
28+
<h1>
29+
Your access key:
30+
</h1>
31+
<p>
32+
<b>
33+
${licenseKey}
34+
</b>
35+
</p>
36+
<p>
37+
Copy this into the textbox in the other tab
38+
</p>
39+
`);
40+
} else {
41+
return res.status(401).send(`
42+
<h1>
43+
Failed to generate an access key
44+
</h1>
45+
`);
46+
}
47+
}
48+
}

src/routes/getUserInfo.ts

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import { getReputation } from "../utils/reputation";
88
import { Category, SegmentUUID } from "../types/segments.model";
99
import { config } from "../config";
1010
import { canSubmit } from "../utils/permissions";
11+
import { oneOf } from "../utils/promise";
1112
const maxRewardTime = config.maxRewardTimePerSegmentInSeconds;
1213

1314
async function dbGetSubmittedSegmentSummary(userID: HashedUserID): Promise<{ minutesSaved: number, segmentCount: number }> {
@@ -115,6 +116,13 @@ async function getPermissions(userID: HashedUserID): Promise<Record<string, bool
115116
return result;
116117
}
117118

119+
async function getFreeChaptersAccess(userID: HashedUserID): Promise<boolean> {
120+
return await oneOf([isUserVIP(userID),
121+
(async () => (await getReputation(userID)) > 0)(),
122+
(async () => !!(await db.prepare("get", `SELECT "timeSubmitted" FROM "sponsorTimes" WHERE "timeSubmitted" < 1590969600000 AND "userID" = ? LIMIT 1`, [userID], { useReplica: true })))()
123+
]);
124+
}
125+
118126
type cases = Record<string, any>
119127

120128
const executeIfFunction = (f: any) =>
@@ -139,7 +147,8 @@ const dbGetValue = (userID: HashedUserID, property: string): Promise<string|Segm
139147
reputation: () => getReputation(userID),
140148
vip: () => isUserVIP(userID),
141149
lastSegmentID: () => dbGetLastSegmentForUser(userID),
142-
permissions: () => getPermissions(userID)
150+
permissions: () => getPermissions(userID),
151+
freeChaptersAccess: () => getFreeChaptersAccess(userID)
143152
})("")(property);
144153
};
145154

@@ -149,7 +158,7 @@ async function getUserInfo(req: Request, res: Response): Promise<Response> {
149158
const defaultProperties: string[] = ["userID", "userName", "minutesSaved", "segmentCount", "ignoredSegmentCount",
150159
"viewCount", "ignoredViewCount", "warnings", "warningReason", "reputation",
151160
"vip", "lastSegmentID"];
152-
const allProperties: string[] = [...defaultProperties, "banned", "permissions"];
161+
const allProperties: string[] = [...defaultProperties, "banned", "permissions", "freeChaptersAccess"];
153162
let paramValues: string[] = req.query.values
154163
? JSON.parse(req.query.values as string)
155164
: req.query.value

src/routes/verifyToken.ts

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
import axios from "axios";
2+
import { Request, Response } from "express";
3+
import { config } from "../config";
4+
import { privateDB } from "../databases/databases";
5+
import { Logger } from "../utils/logger";
6+
import { getPatreonIdentity, PatronStatus, refreshToken, TokenType } from "../utils/tokenUtils";
7+
import FormData from "form-data";
8+
9+
interface VerifyTokenRequest extends Request {
10+
query: {
11+
licenseKey: string;
12+
}
13+
}
14+
15+
export async function verifyTokenRequest(req: VerifyTokenRequest, res: Response): Promise<Response> {
16+
const { query: { licenseKey } } = req;
17+
18+
if (!licenseKey) {
19+
return res.status(400).send("Invalid request");
20+
}
21+
22+
const tokens = (await privateDB.prepare("get", `SELECT "accessToken", "refreshToken", "expiresIn" from "oauthLicenseKeys" WHERE "licenseKey" = ?`
23+
, [licenseKey])) as {accessToken: string, refreshToken: string, expiresIn: number};
24+
if (tokens) {
25+
const identity = await getPatreonIdentity(tokens.accessToken);
26+
27+
if (tokens.expiresIn < 15 * 24 * 60 * 60) {
28+
refreshToken(TokenType.patreon, licenseKey, tokens.refreshToken);
29+
}
30+
31+
if (identity) {
32+
const membership = identity.included?.[0]?.attributes;
33+
const allowed = !!membership && ((membership.patron_status === PatronStatus.active && membership.currently_entitled_amount_cents > 0)
34+
|| (membership.patron_status === PatronStatus.former && membership.campaign_lifetime_support_cents > 300));
35+
36+
return res.status(200).send({
37+
allowed
38+
});
39+
} else {
40+
return res.status(500);
41+
}
42+
} else {
43+
// Check Local
44+
const result = await privateDB.prepare("get", `SELECT "licenseKey" from "licenseKeys" WHERE "licenseKey" = ?`, [licenseKey]);
45+
if (result) {
46+
return res.status(200).send({
47+
allowed: true
48+
});
49+
} else {
50+
// Gumroad
51+
return res.status(200).send({
52+
allowed: await checkAllGumroadProducts(licenseKey)
53+
});
54+
}
55+
56+
}
57+
}
58+
59+
async function checkAllGumroadProducts(licenseKey: string): Promise<boolean> {
60+
for (const link of config.gumroad.productPermalinks) {
61+
try {
62+
const formData = new FormData();
63+
formData.append("product_permalink", link);
64+
formData.append("license_key", licenseKey);
65+
66+
const result = await axios.request({
67+
url: "https://api.gumroad.com/v2/licenses/verify",
68+
data: formData,
69+
method: "POST",
70+
headers: formData.getHeaders()
71+
});
72+
73+
const allowed = result.status === 200 && result.data?.success;
74+
if (allowed) return allowed;
75+
} catch (e) {
76+
Logger.error(`Gumroad fetch for ${link} failed: ${e}`);
77+
}
78+
}
79+
80+
return false;
81+
}

src/types/config.model.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,15 @@ export interface SBSConfig {
6565
dumpDatabase?: DumpDatabase;
6666
diskCacheURL: string;
6767
crons: CronJobOptions;
68+
patreon: {
69+
clientId: string,
70+
clientSecret: string,
71+
minPrice: number,
72+
redirectUri: string
73+
}
74+
gumroad: {
75+
productPermalinks: string[],
76+
}
6877
}
6978

7079
export interface WebhookConfig {

0 commit comments

Comments
 (0)