mirror of
https://github.com/ajayyy/SponsorBlockServer.git
synced 2026-01-07 09:09:00 +03:00
Merge branch 'master' of https://github.com/ajayyy/SponsorBlockServer into fullVideoLabels
This commit is contained in:
21
src/app.ts
21
src/app.ts
@@ -43,13 +43,13 @@ import ExpressPromiseRouter from "express-promise-router";
|
||||
import { Server } from "http";
|
||||
import { youtubeApiProxy } from "./routes/youtubeApiProxy";
|
||||
import { getChapterNames } from "./routes/getChapterNames";
|
||||
import { postRating } from "./routes/ratings/postRating";
|
||||
import { getRating } from "./routes/ratings/getRating";
|
||||
import { postClearCache as ratingPostClearCache } from "./routes/ratings/postClearCache";
|
||||
import { getTopCategoryUsers } from "./routes/getTopCategoryUsers";
|
||||
import { addUserAsTempVIP } from "./routes/addUserAsTempVIP";
|
||||
import { endpoint as getVideoLabels } from "./routes/getVideoLabel";
|
||||
import { getVideoLabelsByHash } from "./routes/getVideoLabelByHash";
|
||||
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).
|
||||
@@ -77,15 +77,14 @@ export function createServer(callback: () => void): Server {
|
||||
return app.listen(config.port, callback);
|
||||
}
|
||||
|
||||
/* eslint-disable @typescript-eslint/no-misused-promises */
|
||||
function setupRoutes(router: Router) {
|
||||
// Rate limit endpoint lists
|
||||
const voteEndpoints: RequestHandler[] = [voteOnSponsorTime];
|
||||
const viewEndpoints: RequestHandler[] = [viewedVideoSponsorTime];
|
||||
const postRateEndpoints: RequestHandler[] = [postRating];
|
||||
if (config.rateLimit) {
|
||||
if (config.rateLimit.vote) voteEndpoints.unshift(rateLimitMiddleware(config.rateLimit.vote, voteGetUserID));
|
||||
if (config.rateLimit.view) viewEndpoints.unshift(rateLimitMiddleware(config.rateLimit.view));
|
||||
if (config.rateLimit.rate) postRateEndpoints.unshift(rateLimitMiddleware(config.rateLimit.rate));
|
||||
}
|
||||
|
||||
//add the get function
|
||||
@@ -198,11 +197,10 @@ function setupRoutes(router: Router) {
|
||||
|
||||
router.get("/api/lockReason", getLockReason);
|
||||
|
||||
// ratings
|
||||
router.get("/api/ratings/rate/:prefix", getRating);
|
||||
router.get("/api/ratings/rate", getRating);
|
||||
router.post("/api/ratings/rate", postRateEndpoints);
|
||||
router.post("/api/ratings/clearCache", ratingPostClearCache);
|
||||
router.post("/api/feature", addFeature);
|
||||
|
||||
router.get("/api/generateToken/:type", generateTokenRequest);
|
||||
router.get("/api/verifyToken", verifyTokenRequest);
|
||||
|
||||
// labels
|
||||
router.get("/api/videoLabels", getVideoLabels);
|
||||
@@ -218,4 +216,5 @@ function setupRoutes(router: Router) {
|
||||
res.sendFile("./databases/sponsorTimes.db", { root: "./" });
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
/* eslint-enable @typescript-eslint/no-misused-promises */
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import fs from "fs";
|
||||
import { SBSConfig } from "./types/config.model";
|
||||
import packageJson from "../package.json";
|
||||
import { isBoolean, isNumber } from "lodash";
|
||||
import { isNumber } from "lodash";
|
||||
|
||||
const isTestMode = process.env.npm_lifecycle_script === packageJson.scripts.test;
|
||||
const configFile = process.env.TEST_POSTGRES ? "ci.json"
|
||||
@@ -20,7 +20,7 @@ addDefaults(config, {
|
||||
privateDBSchema: "./databases/_private.db.sql",
|
||||
readOnly: false,
|
||||
webhooks: [],
|
||||
categoryList: ["sponsor", "selfpromo", "exclusive_access", "interaction", "intro", "outro", "preview", "music_offtopic", "filler", "poi_highlight"],
|
||||
categoryList: ["sponsor", "selfpromo", "exclusive_access", "interaction", "intro", "outro", "preview", "music_offtopic", "filler", "poi_highlight", "chapter"],
|
||||
categorySupport: {
|
||||
sponsor: ["skip", "mute", "full"],
|
||||
selfpromo: ["skip", "mute", "full"],
|
||||
@@ -42,6 +42,9 @@ addDefaults(config, {
|
||||
discordNeuralBlockRejectWebhookURL: null,
|
||||
discordFailedReportChannelWebhookURL: null,
|
||||
discordReportChannelWebhookURL: null,
|
||||
discordMaliciousReportWebhookURL: null,
|
||||
minReputationToSubmitChapter: 0,
|
||||
minReputationToSubmitFiller: 0,
|
||||
getTopUsersCacheTimeMinutes: 240,
|
||||
globalSalt: null,
|
||||
mode: "",
|
||||
@@ -59,12 +62,6 @@ addDefaults(config, {
|
||||
max: 10,
|
||||
statusCode: 200,
|
||||
message: "OK",
|
||||
},
|
||||
rate: {
|
||||
windowMs: 900000,
|
||||
max: 20,
|
||||
statusCode: 200,
|
||||
message: "Success",
|
||||
}
|
||||
},
|
||||
userCounterURL: null,
|
||||
@@ -76,7 +73,25 @@ addDefaults(config, {
|
||||
user: "",
|
||||
host: "",
|
||||
password: "",
|
||||
port: 5432
|
||||
port: 5432,
|
||||
max: 10,
|
||||
idleTimeoutMillis: 10000,
|
||||
maxTries: 3,
|
||||
maxConcurrentRequests: 3500
|
||||
},
|
||||
postgresReadOnly: {
|
||||
enabled: false,
|
||||
weight: 1,
|
||||
user: "",
|
||||
host: "",
|
||||
password: "",
|
||||
port: 5432,
|
||||
readTimeout: 250,
|
||||
max: 10,
|
||||
idleTimeoutMillis: 10000,
|
||||
maxTries: 3,
|
||||
fallbackOnFail: true,
|
||||
maxConcurrentRequests: 3500
|
||||
},
|
||||
dumpDatabase: {
|
||||
enabled: false,
|
||||
@@ -112,9 +127,7 @@ addDefaults(config, {
|
||||
name: "ratings"
|
||||
}]
|
||||
},
|
||||
diskCache: {
|
||||
max: 10737418240
|
||||
},
|
||||
diskCacheURL: null,
|
||||
crons: null,
|
||||
redis: {
|
||||
enabled: false,
|
||||
@@ -122,7 +135,18 @@ addDefaults(config, {
|
||||
host: "",
|
||||
port: 0
|
||||
},
|
||||
disableOfflineQueue: true
|
||||
disableOfflineQueue: true,
|
||||
expiryTime: 24 * 60 * 60,
|
||||
getTimeout: 40
|
||||
},
|
||||
patreon: {
|
||||
clientId: "",
|
||||
clientSecret: "",
|
||||
minPrice: 0,
|
||||
redirectUri: "https://sponsor.ajay.app/api/generateToken/patreon"
|
||||
},
|
||||
gumroad: {
|
||||
productPermalinks: ["sponsorblock"]
|
||||
}
|
||||
});
|
||||
loadFromEnv(config);
|
||||
@@ -167,12 +191,12 @@ function loadFromEnv(config: SBSConfig, prefix = "") {
|
||||
const fullKey = (prefix ? `${prefix}_` : "") + key;
|
||||
const data = config[key];
|
||||
|
||||
if (typeof data === "object" && !Array.isArray(data)) {
|
||||
if (data && typeof data === "object" && !Array.isArray(data)) {
|
||||
loadFromEnv(data, fullKey);
|
||||
} else if (process.env[fullKey]) {
|
||||
const value = process.env[fullKey];
|
||||
if (isNumber(value)) {
|
||||
config[key] = parseInt(value, 10);
|
||||
config[key] = parseFloat(value);
|
||||
} else if (value.toLowerCase() === "true" || value.toLowerCase() === "false") {
|
||||
config[key] = value === "true";
|
||||
} else if (key === "newLeafURLs") {
|
||||
|
||||
@@ -57,7 +57,7 @@ export const archiveDownvoteSegment = async (dayLimit: number, voteLimit: number
|
||||
|
||||
const DownvoteSegmentArchiveJob = new CronJob(
|
||||
jobConfig?.schedule || "0 0 * * * 0",
|
||||
() => archiveDownvoteSegment(jobConfig?.timeThresholdInDays, jobConfig?.voteThreshold)
|
||||
() => void archiveDownvoteSegment(jobConfig?.timeThresholdInDays, jobConfig?.voteThreshold)
|
||||
);
|
||||
|
||||
if (serverConfig?.crons?.enabled && jobConfig && !jobConfig.schedule) {
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
export interface QueryOption {
|
||||
useReplica?: boolean;
|
||||
forceReplica?: boolean;
|
||||
}
|
||||
|
||||
export interface IDatabase {
|
||||
init(): Promise<void>;
|
||||
|
||||
prepare(type: QueryType, query: string, params?: any[]): Promise<any | any[] | void>;
|
||||
prepare(type: QueryType, query: string, params?: any[], options?: QueryOption): Promise<any | any[] | void>;
|
||||
}
|
||||
|
||||
export type QueryType = "get" | "all" | "run";
|
||||
export type QueryType = "get" | "all" | "run";
|
||||
@@ -1,8 +1,10 @@
|
||||
import { Logger } from "../utils/logger";
|
||||
import { IDatabase, QueryType } from "./IDatabase";
|
||||
import { Client, Pool, PoolClient, types } from "pg";
|
||||
import { IDatabase, QueryOption, QueryType } from "./IDatabase";
|
||||
import { Client, Pool, QueryResult, types } from "pg";
|
||||
|
||||
import fs from "fs";
|
||||
import { CustomPostgresConfig, CustomPostgresReadOnlyConfig } from "../types/config.model";
|
||||
import { timeoutPomise, PromiseWithState, savePromiseState, nextFulfilment } from "../utils/promise";
|
||||
|
||||
// return numeric (pg_type oid=1700) as float
|
||||
types.setTypeParser(1700, function(val) {
|
||||
@@ -14,13 +16,58 @@ types.setTypeParser(20, function(val) {
|
||||
return parseInt(val, 10);
|
||||
});
|
||||
|
||||
export interface DatabaseConfig {
|
||||
dbSchemaFileName: string,
|
||||
dbSchemaFolder: string,
|
||||
fileNamePrefix: string,
|
||||
readOnly: boolean,
|
||||
createDbIfNotExists: boolean,
|
||||
postgres: CustomPostgresConfig,
|
||||
postgresReadOnly: CustomPostgresReadOnlyConfig
|
||||
}
|
||||
|
||||
export class Postgres implements IDatabase {
|
||||
private pool: Pool;
|
||||
private lastPoolFail = 0;
|
||||
|
||||
constructor(private config: Record<string, any>) {}
|
||||
private poolRead: Pool;
|
||||
private lastPoolReadFail = 0;
|
||||
|
||||
private concurrentRequests = 0;
|
||||
private concurrentReadRequests = 0;
|
||||
|
||||
constructor(private config: DatabaseConfig) {}
|
||||
|
||||
async init(): Promise<void> {
|
||||
this.pool = new Pool(this.config.postgres);
|
||||
this.pool = new Pool({
|
||||
...this.config.postgres
|
||||
});
|
||||
this.pool.on("error", (err, client) => {
|
||||
Logger.error(err.stack);
|
||||
this.lastPoolFail = Date.now();
|
||||
|
||||
try {
|
||||
client.release(true);
|
||||
} catch (err) {
|
||||
Logger.error(`pool (postgres): ${err}`);
|
||||
}
|
||||
});
|
||||
|
||||
if (this.config.postgresReadOnly && this.config.postgresReadOnly.enabled) {
|
||||
this.poolRead = new Pool({
|
||||
...this.config.postgresReadOnly
|
||||
});
|
||||
this.poolRead.on("error", (err, client) => {
|
||||
Logger.error(err.stack);
|
||||
this.lastPoolReadFail = Date.now();
|
||||
|
||||
try {
|
||||
client.release(true);
|
||||
} catch (err) {
|
||||
Logger.error(`poolRead (postgres): ${err}`);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (!this.config.readOnly) {
|
||||
if (this.config.createDbIfNotExists) {
|
||||
@@ -43,7 +90,7 @@ export class Postgres implements IDatabase {
|
||||
}
|
||||
}
|
||||
|
||||
async prepare(type: QueryType, query: string, params?: any[]): Promise<any[]> {
|
||||
async prepare(type: QueryType, query: string, params?: any[], options: QueryOption = {}): Promise<any[]> {
|
||||
// Convert query to use numbered parameters
|
||||
let count = 1;
|
||||
for (let char = 0; char < query.length; char++) {
|
||||
@@ -55,31 +102,96 @@ export class Postgres implements IDatabase {
|
||||
|
||||
Logger.debug(`prepare (postgres): type: ${type}, query: ${query}, params: ${params}`);
|
||||
|
||||
let client: PoolClient;
|
||||
try {
|
||||
client = await this.pool.connect();
|
||||
const queryResult = await client.query({ text: query, values: params });
|
||||
|
||||
switch (type) {
|
||||
case "get": {
|
||||
const value = queryResult.rows[0];
|
||||
Logger.debug(`result (postgres): ${JSON.stringify(value)}`);
|
||||
return value;
|
||||
}
|
||||
case "all": {
|
||||
const values = queryResult.rows;
|
||||
Logger.debug(`result (postgres): ${JSON.stringify(values)}`);
|
||||
return values;
|
||||
}
|
||||
case "run": {
|
||||
break;
|
||||
}
|
||||
if (this.config.readOnly) {
|
||||
if (this.concurrentReadRequests > this.config.postgresReadOnly?.maxConcurrentRequests) {
|
||||
Logger.error(`prepare (postgres): cancelling read query because too many concurrent requests, query: ${query}`);
|
||||
throw new Error("Too many concurrent requests");
|
||||
}
|
||||
} catch (err) {
|
||||
Logger.error(`prepare (postgres): ${err}`);
|
||||
} finally {
|
||||
client?.release();
|
||||
|
||||
this.concurrentReadRequests++;
|
||||
} else {
|
||||
if (this.concurrentRequests > this.config.postgres.maxConcurrentRequests) {
|
||||
Logger.error(`prepare (postgres): cancelling query because too many concurrent requests, query: ${query}`);
|
||||
throw new Error("Too many concurrent requests");
|
||||
}
|
||||
|
||||
this.concurrentRequests++;
|
||||
}
|
||||
|
||||
const pendingQueries: PromiseWithState<QueryResult<any>>[] = [];
|
||||
let tries = 0;
|
||||
let lastPool: Pool = null;
|
||||
const maxTries = () => (lastPool === this.pool
|
||||
? this.config.postgres.maxTries : this.config.postgresReadOnly.maxTries);
|
||||
do {
|
||||
tries++;
|
||||
|
||||
try {
|
||||
lastPool = this.getPool(type, options);
|
||||
|
||||
pendingQueries.push(savePromiseState(lastPool.query({ text: query, values: params })));
|
||||
const currentPromises = [...pendingQueries];
|
||||
if (options.useReplica && maxTries() - tries > 1) currentPromises.push(savePromiseState(timeoutPomise(this.config.postgresReadOnly.readTimeout)));
|
||||
const queryResult = await nextFulfilment(currentPromises);
|
||||
|
||||
if (this.config.readOnly) {
|
||||
this.concurrentReadRequests--;
|
||||
} else {
|
||||
this.concurrentRequests--;
|
||||
}
|
||||
|
||||
switch (type) {
|
||||
case "get": {
|
||||
const value = queryResult.rows[0];
|
||||
Logger.debug(`result (postgres): ${JSON.stringify(value)}`);
|
||||
return value;
|
||||
}
|
||||
case "all": {
|
||||
const values = queryResult.rows;
|
||||
Logger.debug(`result (postgres): ${JSON.stringify(values)}`);
|
||||
return values;
|
||||
}
|
||||
case "run": {
|
||||
return;
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
if (lastPool === this.pool) {
|
||||
// Only applies if it is get or all request
|
||||
options.forceReplica = true;
|
||||
} else if (lastPool === this.poolRead && maxTries() - tries <= 1) {
|
||||
options.useReplica = false;
|
||||
}
|
||||
|
||||
Logger.error(`prepare (postgres) try ${tries}: ${err}`);
|
||||
}
|
||||
} while (this.isReadQuery(type) && tries < maxTries());
|
||||
|
||||
if (this.config.readOnly) {
|
||||
this.concurrentReadRequests--;
|
||||
} else {
|
||||
this.concurrentRequests--;
|
||||
}
|
||||
|
||||
throw new Error(`prepare (postgres): ${type} ${query} failed after ${tries} tries`);
|
||||
}
|
||||
|
||||
private getPool(type: string, options: QueryOption): Pool {
|
||||
const readAvailable = this.poolRead && options.useReplica && this.isReadQuery(type);
|
||||
const ignroreReadDueToFailure = this.config.postgresReadOnly.fallbackOnFail
|
||||
&& this.lastPoolReadFail > Date.now() - 1000 * 30;
|
||||
const readDueToFailure = this.config.postgresReadOnly.fallbackOnFail
|
||||
&& this.lastPoolFail > Date.now() - 1000 * 30;
|
||||
if (readAvailable && !ignroreReadDueToFailure && (options.forceReplica || readDueToFailure ||
|
||||
Math.random() > 1 / (this.config.postgresReadOnly.weight + 1))) {
|
||||
return this.poolRead;
|
||||
} else {
|
||||
return this.pool;
|
||||
}
|
||||
}
|
||||
|
||||
private isReadQuery(type: string): boolean {
|
||||
return type === "get" || type === "all";
|
||||
}
|
||||
|
||||
private async createDB() {
|
||||
@@ -98,7 +210,7 @@ export class Postgres implements IDatabase {
|
||||
);
|
||||
}
|
||||
|
||||
client.end();
|
||||
client.end().catch(err => Logger.error(`closing db (postgres): ${err}`));
|
||||
}
|
||||
|
||||
private async upgradeDB(fileNamePrefix: string, schemaFolder: string) {
|
||||
|
||||
@@ -93,9 +93,7 @@ export class Sqlite implements IDatabase {
|
||||
}
|
||||
|
||||
private static processUpgradeQuery(query: string): string {
|
||||
const result = query.replace(/^.*--!sqlite-ignore/gm, "");
|
||||
|
||||
return result;
|
||||
return query.replace(/^.*--!sqlite-ignore/gm, "");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -17,12 +17,13 @@ if (config.mysql) {
|
||||
readOnly: config.readOnly,
|
||||
createDbIfNotExists: config.createDatabaseIfNotExist,
|
||||
postgres: {
|
||||
user: config.postgres?.user,
|
||||
host: config.postgres?.host,
|
||||
...config.postgres,
|
||||
database: "sponsorTimes",
|
||||
password: config.postgres?.password,
|
||||
port: config.postgres?.port,
|
||||
}
|
||||
},
|
||||
postgresReadOnly: config.postgresReadOnly ? {
|
||||
...config.postgresReadOnly,
|
||||
database: "sponsorTimes"
|
||||
} : null
|
||||
});
|
||||
|
||||
privateDB = new Postgres({
|
||||
@@ -32,12 +33,13 @@ if (config.mysql) {
|
||||
readOnly: config.readOnly,
|
||||
createDbIfNotExists: config.createDatabaseIfNotExist,
|
||||
postgres: {
|
||||
user: config.postgres?.user,
|
||||
host: config.postgres?.host,
|
||||
database: "privateDB",
|
||||
password: config.postgres?.password,
|
||||
port: config.postgres?.port,
|
||||
}
|
||||
...config.postgres,
|
||||
database: "privateDB"
|
||||
},
|
||||
postgresReadOnly: config.postgresReadOnly ? {
|
||||
...config.postgresReadOnly,
|
||||
database: "privateDB"
|
||||
} : null
|
||||
});
|
||||
} else {
|
||||
db = new Sqlite({
|
||||
|
||||
@@ -27,4 +27,4 @@ async function init() {
|
||||
}).setTimeout(15000);
|
||||
}
|
||||
|
||||
init();
|
||||
init().catch((err) => Logger.error(err));
|
||||
@@ -1,35 +1,42 @@
|
||||
import { getIP } from "../utils/getIP";
|
||||
import { getHash } from "../utils/getHash";
|
||||
import { getHashCache } from "../utils/getHashCache";
|
||||
import rateLimit, { RateLimitRequestHandler } from "express-rate-limit";
|
||||
import rateLimit from "express-rate-limit";
|
||||
import { RateLimitConfig } from "../types/config.model";
|
||||
import { Request } from "express";
|
||||
import { Request, RequestHandler } from "express";
|
||||
import { isUserVIP } from "../utils/isUserVIP";
|
||||
import { UserID } from "../types/user.model";
|
||||
import RedisStore from "rate-limit-redis";
|
||||
import RedisStore, { RedisReply } from "rate-limit-redis";
|
||||
import redis from "../utils/redis";
|
||||
import { config } from "../config";
|
||||
import { Logger } from "../utils/logger";
|
||||
|
||||
export function rateLimitMiddleware(limitConfig: RateLimitConfig, getUserID?: (req: Request) => UserID): RateLimitRequestHandler {
|
||||
return rateLimit({
|
||||
windowMs: limitConfig.windowMs,
|
||||
max: limitConfig.max,
|
||||
message: limitConfig.message,
|
||||
statusCode: limitConfig.statusCode,
|
||||
legacyHeaders: false,
|
||||
standardHeaders: false,
|
||||
keyGenerator: (req) => {
|
||||
return getHash(getIP(req), 1);
|
||||
},
|
||||
handler: async (req, res, next) => {
|
||||
if (getUserID === undefined || !await isUserVIP(await getHashCache(getUserID(req)))) {
|
||||
return res.status(limitConfig.statusCode).send(limitConfig.message);
|
||||
} else {
|
||||
return next();
|
||||
}
|
||||
},
|
||||
store: config.redis?.enabled ? new RedisStore({
|
||||
sendCommand: (...args: string[]) => redis.sendCommand(args),
|
||||
}) : null,
|
||||
});
|
||||
export function rateLimitMiddleware(limitConfig: RateLimitConfig, getUserID?: (req: Request) => UserID): RequestHandler {
|
||||
try {
|
||||
return rateLimit({
|
||||
windowMs: limitConfig.windowMs,
|
||||
max: limitConfig.max,
|
||||
message: limitConfig.message,
|
||||
statusCode: limitConfig.statusCode,
|
||||
legacyHeaders: false,
|
||||
standardHeaders: false,
|
||||
keyGenerator: (req) => {
|
||||
return getHash(getIP(req), 1);
|
||||
},
|
||||
// eslint-disable-next-line @typescript-eslint/no-misused-promises
|
||||
handler: async (req, res, next) => {
|
||||
if (getUserID === undefined || !await isUserVIP(await getHashCache(getUserID(req)))) {
|
||||
return res.status(limitConfig.statusCode).send(limitConfig.message);
|
||||
} else {
|
||||
return next();
|
||||
}
|
||||
},
|
||||
store: config.redis?.enabled ? new RedisStore({
|
||||
sendCommand: (...args: string[]) => redis.sendCommand(args).catch((err) => Logger.error(err)) as Promise<RedisReply>,
|
||||
}) : null,
|
||||
});
|
||||
} catch (e) {
|
||||
Logger.error(`Rate limit error: ${e}`);
|
||||
return (req, res, next) => next();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,8 +6,10 @@ import { getHash } from "../utils/getHash";
|
||||
import { NextFunction, Request, Response } from "express";
|
||||
|
||||
export function userCounter(req: Request, res: Response, next: NextFunction): void {
|
||||
axios.post(`${config.userCounterURL}/api/v1/addIP?hashedIP=${getHash(getIP(req), 1)}`)
|
||||
.catch(() => Logger.debug(`Failing to connect to user counter at: ${config.userCounterURL}`));
|
||||
if (req.method !== "OPTIONS") {
|
||||
axios.post(`${config.userCounterURL}/api/v1/addIP?hashedIP=${getHash(getIP(req), 1)}`)
|
||||
.catch(() => Logger.debug(`Failing to connect to user counter at: ${config.userCounterURL}`));
|
||||
}
|
||||
|
||||
next();
|
||||
}
|
||||
|
||||
74
src/routes/addFeature.ts
Normal file
74
src/routes/addFeature.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
import { getHashCache } from "../utils/getHashCache";
|
||||
import { db } from "../databases/databases";
|
||||
import { config } from "../config";
|
||||
import { Request, Response } from "express";
|
||||
import { isUserVIP } from "../utils/isUserVIP";
|
||||
import { Feature, HashedUserID, UserID } from "../types/user.model";
|
||||
import { Logger } from "../utils/logger";
|
||||
import { QueryCacher } from "../utils/queryCacher";
|
||||
|
||||
interface AddFeatureRequest extends Request {
|
||||
body: {
|
||||
userID: HashedUserID;
|
||||
adminUserID: string;
|
||||
feature: string;
|
||||
enabled: string;
|
||||
}
|
||||
}
|
||||
|
||||
const allowedFeatures = {
|
||||
vip: [
|
||||
Feature.ChapterSubmitter,
|
||||
Feature.FillerSubmitter
|
||||
],
|
||||
admin: [
|
||||
Feature.ChapterSubmitter,
|
||||
Feature.FillerSubmitter
|
||||
]
|
||||
};
|
||||
|
||||
export async function addFeature(req: AddFeatureRequest, res: Response): Promise<Response> {
|
||||
const { body: { userID, adminUserID } } = req;
|
||||
const feature = parseInt(req.body.feature) as Feature;
|
||||
const enabled = req.body?.enabled !== "false";
|
||||
|
||||
if (!userID || !adminUserID) {
|
||||
// invalid request
|
||||
return res.sendStatus(400);
|
||||
}
|
||||
|
||||
// hash the userID
|
||||
const adminUserIDInput = await getHashCache(adminUserID as UserID);
|
||||
const isAdmin = adminUserIDInput === config.adminUserID;
|
||||
const isVIP = (await isUserVIP(adminUserIDInput)) || isAdmin;
|
||||
|
||||
if (!isVIP) {
|
||||
// not authorized
|
||||
return res.sendStatus(403);
|
||||
}
|
||||
|
||||
try {
|
||||
const currentAllowedFeatures = isAdmin ? allowedFeatures.admin : allowedFeatures.vip;
|
||||
if (currentAllowedFeatures.includes(feature)) {
|
||||
if (enabled) {
|
||||
const featureAdded = await db.prepare("get", 'SELECT "feature" from "userFeatures" WHERE "userID" = ? AND "feature" = ?', [userID, feature]);
|
||||
if (!featureAdded) {
|
||||
await db.prepare("run", 'INSERT INTO "userFeatures" ("userID", "feature", "issuerUserID", "timeSubmitted") VALUES(?, ?, ?, ?)'
|
||||
, [userID, feature, adminUserID, Date.now()]);
|
||||
}
|
||||
} else {
|
||||
await db.prepare("run", 'DELETE FROM "userFeatures" WHERE "userID" = ? AND "feature" = ?', [userID, feature]);
|
||||
}
|
||||
|
||||
QueryCacher.clearFeatureCache(userID, feature);
|
||||
} else {
|
||||
return res.status(400).send("Invalid feature");
|
||||
}
|
||||
|
||||
return res.sendStatus(200);
|
||||
} catch (e) {
|
||||
Logger.error(e as string);
|
||||
|
||||
return res.sendStatus(500);
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,5 @@
|
||||
import { VideoID } from "../types/segments.model";
|
||||
import { YouTubeAPI } from "../utils/youtubeApi";
|
||||
import { APIVideoInfo } from "../types/youtubeApi.model";
|
||||
import { config } from "../config";
|
||||
import { getVideoDetails } from "../utils/getVideoDetails";
|
||||
import { getHashCache } from "../utils/getHashCache";
|
||||
import { privateDB } from "../databases/databases";
|
||||
import { Request, Response } from "express";
|
||||
@@ -20,15 +18,11 @@ interface AddUserAsTempVIPRequest extends Request {
|
||||
}
|
||||
}
|
||||
|
||||
function getYouTubeVideoInfo(videoID: VideoID, ignoreCache = false): Promise<APIVideoInfo> {
|
||||
return (config.newLeafURLs) ? YouTubeAPI.listVideos(videoID, ignoreCache) : null;
|
||||
}
|
||||
|
||||
const getChannelInfo = async (videoID: VideoID): Promise<{id: string | null, name: string | null }> => {
|
||||
const videoInfo = await getYouTubeVideoInfo(videoID);
|
||||
const videoInfo = await getVideoDetails(videoID);
|
||||
return {
|
||||
id: videoInfo?.data?.authorId,
|
||||
name: videoInfo?.data?.author
|
||||
id: videoInfo?.authorId,
|
||||
name: videoInfo?.authorName
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ import { config } from "../config";
|
||||
import util from "util";
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
import { ChildProcess, exec, ExecOptions, spawn } from "child_process";
|
||||
import { exec, ExecOptions } from "child_process";
|
||||
const unlink = util.promisify(fs.unlink);
|
||||
|
||||
const ONE_MINUTE = 1000 * 60;
|
||||
@@ -44,7 +44,7 @@ const credentials: ExecOptions = {
|
||||
PGPASSWORD: String(config.postgres.password),
|
||||
PGDATABASE: "sponsorTimes",
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
interface TableDumpList {
|
||||
fileName: string;
|
||||
@@ -75,6 +75,7 @@ function removeOutdatedDumps(exportPath: string): Promise<void> {
|
||||
}, {});
|
||||
|
||||
// read files in export directory
|
||||
// eslint-disable-next-line @typescript-eslint/no-misused-promises
|
||||
fs.readdir(exportPath, async (err: any, files: string[]) => {
|
||||
if (err) Logger.error(err);
|
||||
if (err) return resolve();
|
||||
@@ -232,7 +233,7 @@ async function queueDump(): Promise<void> {
|
||||
|
||||
resolve(error ? stderr : stdout);
|
||||
});
|
||||
})
|
||||
});
|
||||
|
||||
dumpFiles.push({
|
||||
fileName,
|
||||
|
||||
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 license 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 license key
|
||||
</h1>
|
||||
`);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -22,7 +22,7 @@ export async function getChapterNames(req: Request, res: Response): Promise<Resp
|
||||
const descriptions = await db.prepare("all", `
|
||||
SELECT "description"
|
||||
FROM "sponsorTimes"
|
||||
WHERE ("votes" > 0 OR ("views" > 100 AND "votes" >= 0)) AND "videoID" IN (
|
||||
WHERE ("locked" = 1 OR "votes" > 0 OR ("views" > 25 AND "votes" >= 0)) AND "videoID" IN (
|
||||
SELECT "videoID"
|
||||
FROM "videoInfo"
|
||||
WHERE "channelID" = ?
|
||||
|
||||
@@ -18,7 +18,7 @@ export async function getSavedTimeForUser(req: Request, res: Response): Promise<
|
||||
userID = await getHashCache(userID);
|
||||
|
||||
try {
|
||||
const row = await db.prepare("get", 'SELECT SUM(((CASE WHEN "endTime" - "startTime" > ? THEN ? ELSE "endTime" - "startTime" END) / 60) * "views") as "minutesSaved" FROM "sponsorTimes" WHERE "userID" = ? AND "votes" > -1 AND "shadowHidden" != 1 ', [maxRewardTimePerSegmentInSeconds, maxRewardTimePerSegmentInSeconds, userID]);
|
||||
const row = await db.prepare("get", 'SELECT SUM(((CASE WHEN "endTime" - "startTime" > ? THEN ? ELSE "endTime" - "startTime" END) / 60) * "views") as "minutesSaved" FROM "sponsorTimes" WHERE "userID" = ? AND "votes" > -1 AND "shadowHidden" != 1 ', [maxRewardTimePerSegmentInSeconds, maxRewardTimePerSegmentInSeconds, userID], { useReplica: true });
|
||||
|
||||
if (row.minutesSaved != null) {
|
||||
return res.send({
|
||||
|
||||
@@ -11,11 +11,16 @@ import { Logger } from "../utils/logger";
|
||||
import { QueryCacher } from "../utils/queryCacher";
|
||||
import { getReputation } from "../utils/reputation";
|
||||
import { getService } from "../utils/getService";
|
||||
import { promiseOrTimeout } from "../utils/promise";
|
||||
|
||||
|
||||
async function prepareCategorySegments(req: Request, videoID: VideoID, service: Service, segments: DBSegment[], cache: SegmentCache = { shadowHiddenSegmentIPs: {} }, useCache: boolean): Promise<Segment[]> {
|
||||
const shouldFilter: boolean[] = await Promise.all(segments.map(async (segment) => {
|
||||
if (segment.votes < -1 && !segment.required) {
|
||||
if (segment.required) {
|
||||
return true; //required - always send
|
||||
}
|
||||
|
||||
if (segment.hidden || segment.votes < -1) {
|
||||
return false; //too untrustworthy, just ignore it
|
||||
}
|
||||
|
||||
@@ -27,17 +32,25 @@ async function prepareCategorySegments(req: Request, videoID: VideoID, service:
|
||||
|
||||
if (cache.shadowHiddenSegmentIPs[videoID] === undefined) cache.shadowHiddenSegmentIPs[videoID] = {};
|
||||
if (cache.shadowHiddenSegmentIPs[videoID][segment.timeSubmitted] === undefined) {
|
||||
if (cache.userHashedIP === undefined && cache.userHashedIPPromise === undefined) {
|
||||
cache.userHashedIPPromise = getHashCache((getIP(req) + config.globalSalt) as IPAddress);
|
||||
}
|
||||
|
||||
const service = getService(req?.query?.service as string);
|
||||
const fetchData = () => privateDB.prepare("all", 'SELECT "hashedIP" FROM "sponsorTimes" WHERE "videoID" = ? AND "timeSubmitted" = ? AND "service" = ?',
|
||||
[videoID, segment.timeSubmitted, service]) as Promise<{ hashedIP: HashedIP }[]>;
|
||||
cache.shadowHiddenSegmentIPs[videoID][segment.timeSubmitted] = await QueryCacher.get(fetchData, shadowHiddenIPKey(videoID, segment.timeSubmitted, service));
|
||||
[videoID, segment.timeSubmitted, service], { useReplica: true }) as Promise<{ hashedIP: HashedIP }[]>;
|
||||
try {
|
||||
cache.shadowHiddenSegmentIPs[videoID][segment.timeSubmitted] = await promiseOrTimeout(QueryCacher.get(fetchData, shadowHiddenIPKey(videoID, segment.timeSubmitted, service)), 150);
|
||||
} catch (e) {
|
||||
// give up on shadowhide for now
|
||||
cache.shadowHiddenSegmentIPs[videoID][segment.timeSubmitted] = null;
|
||||
}
|
||||
}
|
||||
|
||||
const ipList = cache.shadowHiddenSegmentIPs[videoID][segment.timeSubmitted];
|
||||
|
||||
if (ipList?.length > 0 && cache.userHashedIP === undefined) {
|
||||
//hash the IP only if it's strictly necessary
|
||||
cache.userHashedIP = await getHashCache((getIP(req) + config.globalSalt) as IPAddress);
|
||||
cache.userHashedIP = await cache.userHashedIPPromise;
|
||||
}
|
||||
//if this isn't their ip, don't send it to them
|
||||
const shouldShadowHide = cache.shadowHiddenSegmentIPs[videoID][segment.timeSubmitted]?.some(
|
||||
@@ -133,7 +146,7 @@ async function getSegmentsByHash(req: Request, hashedVideoIDPrefix: VideoIDHash,
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
for (const [videoID, videoData] of Object.entries(segmentPerVideoID)) {
|
||||
await Promise.all(Object.entries(segmentPerVideoID).map(async ([videoID, videoData]) => {
|
||||
const data: VideoData = {
|
||||
hash: videoData.hash,
|
||||
segments: [],
|
||||
@@ -153,7 +166,7 @@ async function getSegmentsByHash(req: Request, hashedVideoIDPrefix: VideoIDHash,
|
||||
if (data.segments.length > 0) {
|
||||
segments[videoID] = data;
|
||||
}
|
||||
}
|
||||
}));
|
||||
|
||||
return segments;
|
||||
} catch (err) {
|
||||
@@ -166,9 +179,10 @@ async function getSegmentsFromDBByHash(hashedVideoIDPrefix: VideoIDHash, service
|
||||
const fetchFromDB = () => db
|
||||
.prepare(
|
||||
"all",
|
||||
`SELECT "videoID", "startTime", "endTime", "votes", "locked", "UUID", "userID", "category", "actionType", "videoDuration", "reputation", "shadowHidden", "hashedVideoID", "timeSubmitted", "description" FROM "sponsorTimes"
|
||||
WHERE "hashedVideoID" LIKE ? AND "service" = ? AND "hidden" = 0 ORDER BY "startTime"`,
|
||||
[`${hashedVideoIDPrefix}%`, service]
|
||||
`SELECT "videoID", "startTime", "endTime", "votes", "locked", "UUID", "userID", "category", "actionType", "videoDuration", "hidden", "reputation", "shadowHidden", "hashedVideoID", "timeSubmitted", "description" FROM "sponsorTimes"
|
||||
WHERE "hashedVideoID" LIKE ? AND "service" = ? ORDER BY "startTime"`,
|
||||
[`${hashedVideoIDPrefix}%`, service],
|
||||
{ useReplica: true }
|
||||
) as Promise<DBSegment[]>;
|
||||
|
||||
if (hashedVideoIDPrefix.length === 4) {
|
||||
@@ -182,9 +196,10 @@ async function getSegmentsFromDBByVideoID(videoID: VideoID, service: Service): P
|
||||
const fetchFromDB = () => db
|
||||
.prepare(
|
||||
"all",
|
||||
`SELECT "startTime", "endTime", "votes", "locked", "UUID", "userID", "category", "actionType", "videoDuration", "reputation", "shadowHidden", "timeSubmitted", "description" FROM "sponsorTimes"
|
||||
WHERE "videoID" = ? AND "service" = ? AND "hidden" = 0 ORDER BY "startTime"`,
|
||||
[videoID, service]
|
||||
`SELECT "startTime", "endTime", "votes", "locked", "UUID", "userID", "category", "actionType", "videoDuration", "hidden", "reputation", "shadowHidden", "timeSubmitted", "description" FROM "sponsorTimes"
|
||||
WHERE "videoID" = ? AND "service" = ? ORDER BY "startTime"`,
|
||||
[videoID, service],
|
||||
{ useReplica: true }
|
||||
) as Promise<DBSegment[]>;
|
||||
|
||||
return await QueryCacher.get(fetchFromDB, skipSegmentsKey(videoID, service));
|
||||
@@ -275,6 +290,9 @@ async function chooseSegments(videoID: VideoID, service: Service, segments: DBSe
|
||||
//This allows new less voted items to still sometimes appear to give them a chance at getting votes.
|
||||
//Segments with less than -1 votes are already ignored before this function is called
|
||||
async function buildSegmentGroups(segments: DBSegment[]): Promise<OverlappingSegmentGroup[]> {
|
||||
const reputationPromises = segments.map(segment =>
|
||||
segment.userID ? getReputation(segment.userID).catch((e) => Logger.error(e)) : null);
|
||||
|
||||
//Create groups of segments that are similar to eachother
|
||||
//Segments must be sorted by their startTime so that we can build groups chronologically:
|
||||
//1. As long as the segments' startTime fall inside the currentGroup, we keep adding them to that group
|
||||
@@ -283,7 +301,8 @@ async function buildSegmentGroups(segments: DBSegment[]): Promise<OverlappingSeg
|
||||
let overlappingSegmentsGroups: OverlappingSegmentGroup[] = [];
|
||||
let currentGroup: OverlappingSegmentGroup;
|
||||
let cursor = -1; //-1 to make sure that, even if the 1st segment starts at 0, a new group is created
|
||||
for (const segment of segments) {
|
||||
for (let i = 0; i < segments.length; i++) {
|
||||
const segment = segments[i];
|
||||
if (segment.startTime >= cursor) {
|
||||
currentGroup = { segments: [], votes: 0, reputation: 0, locked: false, required: false };
|
||||
overlappingSegmentsGroups.push(currentGroup);
|
||||
@@ -295,7 +314,7 @@ async function buildSegmentGroups(segments: DBSegment[]): Promise<OverlappingSeg
|
||||
currentGroup.votes += segment.votes;
|
||||
}
|
||||
|
||||
if (segment.userID) segment.reputation = Math.min(segment.reputation, await getReputation(segment.userID));
|
||||
if (segment.userID) segment.reputation = Math.min(segment.reputation, (await reputationPromises[i]) || Infinity);
|
||||
if (segment.reputation > 0) {
|
||||
currentGroup.reputation += segment.reputation;
|
||||
}
|
||||
|
||||
@@ -3,27 +3,44 @@ import { Logger } from "../utils/logger";
|
||||
import { Request, Response } from "express";
|
||||
import os from "os";
|
||||
import redis from "../utils/redis";
|
||||
import { promiseOrTimeout } from "../utils/promise";
|
||||
|
||||
export async function getStatus(req: Request, res: Response): Promise<Response> {
|
||||
const startTime = Date.now();
|
||||
let value = req.params.value as string[] | string;
|
||||
value = Array.isArray(value) ? value[0] : value;
|
||||
let processTime, redisProcessTime = -1;
|
||||
try {
|
||||
const dbVersion = (await db.prepare("get", "SELECT key, value FROM config where key = ?", ["version"])).value;
|
||||
const dbVersion = await promiseOrTimeout(db.prepare("get", "SELECT key, value FROM config where key = ?", ["version"]), 5000)
|
||||
.then(e => {
|
||||
processTime = Date.now() - startTime;
|
||||
return e.value;
|
||||
})
|
||||
.catch(e => {
|
||||
Logger.error(`status: SQL query timed out: ${e}`);
|
||||
return -1;
|
||||
});
|
||||
let statusRequests: unknown = 0;
|
||||
try {
|
||||
const numberRequests = await redis.increment("statusRequest");
|
||||
statusRequests = numberRequests?.[0];
|
||||
} catch (error) { } // eslint-disable-line no-empty
|
||||
const numberRequests = await promiseOrTimeout(redis.increment("statusRequest"), 5000)
|
||||
.then(e => {
|
||||
redisProcessTime = Date.now() - startTime;
|
||||
return e;
|
||||
}).catch(e => {
|
||||
Logger.error(`status: redis increment timed out ${e}`);
|
||||
return [-1];
|
||||
});
|
||||
statusRequests = numberRequests?.[0];
|
||||
|
||||
const statusValues: Record<string, any> = {
|
||||
uptime: process.uptime(),
|
||||
commit: (global as any).HEADCOMMIT || "unknown",
|
||||
db: Number(dbVersion),
|
||||
startTime,
|
||||
processTime: Date.now() - startTime,
|
||||
processTime,
|
||||
redisProcessTime,
|
||||
loadavg: os.loadavg().slice(1), // only return 5 & 15 minute load average
|
||||
statusRequests
|
||||
statusRequests,
|
||||
hostname: os.hostname()
|
||||
};
|
||||
return value ? res.send(JSON.stringify(statusValues[value])) : res.send(statusValues);
|
||||
} catch (err) {
|
||||
|
||||
@@ -4,6 +4,7 @@ import { config } from "../config";
|
||||
import { Request, Response } from "express";
|
||||
|
||||
const MILLISECONDS_IN_MINUTE = 60000;
|
||||
// eslint-disable-next-line @typescript-eslint/no-misused-promises
|
||||
const getTopCategoryUsersWithCache = createMemoryCache(generateTopCategoryUsersStats, config.getTopUsersCacheTimeMinutes * MILLISECONDS_IN_MINUTE);
|
||||
const maxRewardTimePerSegmentInSeconds = config.maxRewardTimePerSegmentInSeconds ?? 86400;
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import { config } from "../config";
|
||||
import { Request, Response } from "express";
|
||||
|
||||
const MILLISECONDS_IN_MINUTE = 60000;
|
||||
// eslint-disable-next-line @typescript-eslint/no-misused-promises
|
||||
const getTopUsersWithCache = createMemoryCache(generateTopUsersStats, config.getTopUsersCacheTimeMinutes * MILLISECONDS_IN_MINUTE);
|
||||
const maxRewardTimePerSegmentInSeconds = config.maxRewardTimePerSegmentInSeconds ?? 86400;
|
||||
|
||||
@@ -34,7 +35,7 @@ async function generateTopUsersStats(sortBy: string, categoryStatsEnabled = fals
|
||||
SUM(((CASE WHEN "sponsorTimes"."endTime" - "sponsorTimes"."startTime" > ? THEN ? ELSE "sponsorTimes"."endTime" - "sponsorTimes"."startTime" END) / 60) * "sponsorTimes"."views") as "minutesSaved",
|
||||
SUM("votes") as "userVotes", ${additionalFields} COALESCE("userNames"."userName", "sponsorTimes"."userID") as "userName" FROM "sponsorTimes" LEFT JOIN "userNames" ON "sponsorTimes"."userID"="userNames"."userID"
|
||||
LEFT JOIN "shadowBannedUsers" ON "sponsorTimes"."userID"="shadowBannedUsers"."userID"
|
||||
WHERE "sponsorTimes"."votes" > -1 AND "sponsorTimes"."shadowHidden" != 1 AND "shadowBannedUsers"."userID" IS NULL
|
||||
WHERE "sponsorTimes"."votes" > -1 AND "sponsorTimes"."shadowHidden" != 1 AND "sponsorTimes"."actionType" != 'chapter' AND "shadowBannedUsers"."userID" IS NULL
|
||||
GROUP BY COALESCE("userName", "sponsorTimes"."userID") HAVING SUM("votes") > 20
|
||||
ORDER BY "${sortBy}" DESC LIMIT 100`, [maxRewardTimePerSegmentInSeconds, maxRewardTimePerSegmentInSeconds]);
|
||||
|
||||
|
||||
@@ -10,14 +10,15 @@ let firefoxUsersCache = 0;
|
||||
|
||||
// By the privacy friendly user counter
|
||||
let apiUsersCache = 0;
|
||||
|
||||
let lastUserCountCheck = 0;
|
||||
|
||||
updateExtensionUsers();
|
||||
|
||||
export async function getTotalStats(req: Request, res: Response): Promise<void> {
|
||||
const userCountQuery = `(SELECT COUNT(*) FROM (SELECT DISTINCT "userID" from "sponsorTimes") t) "userCount",`;
|
||||
|
||||
const row = await db.prepare("get", `SELECT ${req.query.countContributingUsers ? userCountQuery : ""} COUNT(*) as "totalSubmissions",
|
||||
SUM("views") as "viewCount", SUM(("endTime" - "startTime") / 60 * "views") as "minutesSaved" FROM "sponsorTimes" WHERE "shadowHidden" != 1 AND "votes" >= 0`, []);
|
||||
SUM("views") as "viewCount", SUM(("endTime" - "startTime") / 60 * "views") as "minutesSaved" FROM "sponsorTimes" WHERE "shadowHidden" != 1 AND "votes" >= 0 AND "actionType" != 'chapter'`, []);
|
||||
|
||||
if (row !== undefined) {
|
||||
const extensionUsers = chromeUsersCache + firefoxUsersCache;
|
||||
|
||||
@@ -5,16 +5,18 @@ import { Request, Response } from "express";
|
||||
import { Logger } from "../utils/logger";
|
||||
import { HashedUserID, UserID } from "../types/user.model";
|
||||
import { getReputation } from "../utils/reputation";
|
||||
import { SegmentUUID } from "../types/segments.model";
|
||||
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 }> {
|
||||
try {
|
||||
const row = await db.prepare("get",
|
||||
`SELECT SUM(((CASE WHEN "endTime" - "startTime" > ? THEN ? ELSE "endTime" - "startTime" END) / 60) * "views") as "minutesSaved",
|
||||
`SELECT SUM(CASE WHEN "actionType" = 'chapter' THEN 0 ELSE ((CASE WHEN "endTime" - "startTime" > ? THEN ? ELSE "endTime" - "startTime" END) / 60) * "views" END) as "minutesSaved",
|
||||
count(*) as "segmentCount" FROM "sponsorTimes"
|
||||
WHERE "userID" = ? AND "votes" > -2 AND "shadowHidden" != 1`, [maxRewardTime, maxRewardTime, userID]);
|
||||
WHERE "userID" = ? AND "votes" > -2 AND "shadowHidden" != 1`, [maxRewardTime, maxRewardTime, userID], { useReplica: true });
|
||||
if (row.minutesSaved != null) {
|
||||
return {
|
||||
minutesSaved: row.minutesSaved,
|
||||
@@ -33,7 +35,7 @@ async function dbGetSubmittedSegmentSummary(userID: HashedUserID): Promise<{ min
|
||||
|
||||
async function dbGetIgnoredSegmentCount(userID: HashedUserID): Promise<number> {
|
||||
try {
|
||||
const row = await db.prepare("get", `SELECT COUNT(*) as "ignoredSegmentCount" FROM "sponsorTimes" WHERE "userID" = ? AND ( "votes" <= -2 OR "shadowHidden" = 1 )`, [userID]);
|
||||
const row = await db.prepare("get", `SELECT COUNT(*) as "ignoredSegmentCount" FROM "sponsorTimes" WHERE "userID" = ? AND ( "votes" <= -2 OR "shadowHidden" = 1 )`, [userID], { useReplica: true });
|
||||
return row?.ignoredSegmentCount ?? 0;
|
||||
} catch (err) {
|
||||
return null;
|
||||
@@ -51,7 +53,7 @@ async function dbGetUsername(userID: HashedUserID) {
|
||||
|
||||
async function dbGetViewsForUser(userID: HashedUserID) {
|
||||
try {
|
||||
const row = await db.prepare("get", `SELECT SUM("views") as "viewCount" FROM "sponsorTimes" WHERE "userID" = ? AND "votes" > -2 AND "shadowHidden" != 1`, [userID]);
|
||||
const row = await db.prepare("get", `SELECT SUM("views") as "viewCount" FROM "sponsorTimes" WHERE "userID" = ? AND "votes" > -2 AND "shadowHidden" != 1`, [userID], { useReplica: true });
|
||||
return row?.viewCount ?? 0;
|
||||
} catch (err) {
|
||||
return false;
|
||||
@@ -60,7 +62,7 @@ async function dbGetViewsForUser(userID: HashedUserID) {
|
||||
|
||||
async function dbGetIgnoredViewsForUser(userID: HashedUserID) {
|
||||
try {
|
||||
const row = await db.prepare("get", `SELECT SUM("views") as "ignoredViewCount" FROM "sponsorTimes" WHERE "userID" = ? AND ( "votes" <= -2 OR "shadowHidden" = 1 )`, [userID]);
|
||||
const row = await db.prepare("get", `SELECT SUM("views") as "ignoredViewCount" FROM "sponsorTimes" WHERE "userID" = ? AND ( "votes" <= -2 OR "shadowHidden" = 1 )`, [userID], { useReplica: true });
|
||||
return row?.ignoredViewCount ?? 0;
|
||||
} catch (err) {
|
||||
return false;
|
||||
@@ -69,7 +71,7 @@ async function dbGetIgnoredViewsForUser(userID: HashedUserID) {
|
||||
|
||||
async function dbGetWarningsForUser(userID: HashedUserID): Promise<number> {
|
||||
try {
|
||||
const row = await db.prepare("get", `SELECT COUNT(*) as total FROM "warnings" WHERE "userID" = ? AND "enabled" = 1`, [userID]);
|
||||
const row = await db.prepare("get", `SELECT COUNT(*) as total FROM "warnings" WHERE "userID" = ? AND "enabled" = 1`, [userID], { useReplica: true });
|
||||
return row?.total ?? 0;
|
||||
} catch (err) {
|
||||
Logger.error(`Couldn't get warnings for user ${userID}. returning 0`);
|
||||
@@ -79,7 +81,7 @@ async function dbGetWarningsForUser(userID: HashedUserID): Promise<number> {
|
||||
|
||||
async function dbGetLastSegmentForUser(userID: HashedUserID): Promise<SegmentUUID> {
|
||||
try {
|
||||
const row = await db.prepare("get", `SELECT "UUID" FROM "sponsorTimes" WHERE "userID" = ? ORDER BY "timeSubmitted" DESC LIMIT 1`, [userID]);
|
||||
const row = await db.prepare("get", `SELECT "UUID" FROM "sponsorTimes" WHERE "userID" = ? ORDER BY "timeSubmitted" DESC LIMIT 1`, [userID], { useReplica: true });
|
||||
return row?.UUID ?? null;
|
||||
} catch (err) {
|
||||
return null;
|
||||
@@ -88,7 +90,7 @@ async function dbGetLastSegmentForUser(userID: HashedUserID): Promise<SegmentUUI
|
||||
|
||||
async function dbGetActiveWarningReasonForUser(userID: HashedUserID): Promise<string> {
|
||||
try {
|
||||
const row = await db.prepare("get", `SELECT reason FROM "warnings" WHERE "userID" = ? AND "enabled" = 1 ORDER BY "issueTime" DESC LIMIT 1`, [userID]);
|
||||
const row = await db.prepare("get", `SELECT reason FROM "warnings" WHERE "userID" = ? AND "enabled" = 1 ORDER BY "issueTime" DESC LIMIT 1`, [userID], { useReplica: true });
|
||||
return row?.reason ?? "";
|
||||
} catch (err) {
|
||||
Logger.error(`Couldn't get reason for user ${userID}. returning blank`);
|
||||
@@ -98,13 +100,29 @@ async function dbGetActiveWarningReasonForUser(userID: HashedUserID): Promise<st
|
||||
|
||||
async function dbGetBanned(userID: HashedUserID): Promise<boolean> {
|
||||
try {
|
||||
const row = await db.prepare("get", `SELECT count(*) as "userCount" FROM "shadowBannedUsers" WHERE "userID" = ? LIMIT 1`, [userID]);
|
||||
const row = await db.prepare("get", `SELECT count(*) as "userCount" FROM "shadowBannedUsers" WHERE "userID" = ? LIMIT 1`, [userID], { useReplica: true });
|
||||
return row?.userCount > 0 ?? false;
|
||||
} catch (err) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function getPermissions(userID: HashedUserID): Promise<Record<string, boolean>> {
|
||||
const result: Record<string, boolean> = {};
|
||||
for (const category of config.categoryList) {
|
||||
result[category] = (await canSubmit(userID, category as Category)).canSubmit;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
async function getFreeChaptersAccess(userID: HashedUserID): Promise<boolean> {
|
||||
return await oneOf([isUserVIP(userID),
|
||||
(async () => !!(await db.prepare("get", `SELECT "timeSubmitted" FROM "sponsorTimes" WHERE "reputation" > 0 AND "timeSubmitted" < 1663872563000 AND "userID" = ? LIMIT 1`, [userID], { useReplica: true })))(),
|
||||
(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) =>
|
||||
@@ -119,16 +137,18 @@ const functionSwitch = (cases: cases) => (defaultCase: string) => (key: string)
|
||||
const dbGetValue = (userID: HashedUserID, property: string): Promise<string|SegmentUUID|number> => {
|
||||
return functionSwitch({
|
||||
userID,
|
||||
userName: dbGetUsername(userID),
|
||||
ignoredSegmentCount: dbGetIgnoredSegmentCount(userID),
|
||||
viewCount: dbGetViewsForUser(userID),
|
||||
ignoredViewCount: dbGetIgnoredViewsForUser(userID),
|
||||
warnings: dbGetWarningsForUser(userID),
|
||||
warningReason: dbGetActiveWarningReasonForUser(userID),
|
||||
banned: dbGetBanned(userID),
|
||||
reputation: getReputation(userID),
|
||||
vip: isUserVIP(userID),
|
||||
lastSegmentID: dbGetLastSegmentForUser(userID),
|
||||
userName: () => dbGetUsername(userID),
|
||||
ignoredSegmentCount: () => dbGetIgnoredSegmentCount(userID),
|
||||
viewCount: () => dbGetViewsForUser(userID),
|
||||
ignoredViewCount: () => dbGetIgnoredViewsForUser(userID),
|
||||
warnings: () => dbGetWarningsForUser(userID),
|
||||
warningReason: () => dbGetActiveWarningReasonForUser(userID),
|
||||
banned: () => dbGetBanned(userID),
|
||||
reputation: () => getReputation(userID),
|
||||
vip: () => isUserVIP(userID),
|
||||
lastSegmentID: () => dbGetLastSegmentForUser(userID),
|
||||
permissions: () => getPermissions(userID),
|
||||
freeChaptersAccess: () => getFreeChaptersAccess(userID)
|
||||
})("")(property);
|
||||
};
|
||||
|
||||
@@ -138,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"];
|
||||
const allProperties: string[] = [...defaultProperties, "banned", "permissions", "freeChaptersAccess"];
|
||||
let paramValues: string[] = req.query.values
|
||||
? JSON.parse(req.query.values as string)
|
||||
: req.query.value
|
||||
@@ -180,4 +200,4 @@ export async function endpoint(req: Request, res: Response): Promise<Response> {
|
||||
return res.status(400).send("Invalid values JSON");
|
||||
} else return res.sendStatus(500);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,6 +21,7 @@ async function dbGetUserSummary(userID: HashedUserID, fetchCategoryStats: boolea
|
||||
SUM(CASE WHEN "category" = 'poi_highlight' THEN 1 ELSE 0 END) as "categorySumHighlight",
|
||||
SUM(CASE WHEN "category" = 'filler' THEN 1 ELSE 0 END) as "categorySumFiller",
|
||||
SUM(CASE WHEN "category" = 'exclusive_access' THEN 1 ELSE 0 END) as "categorySumExclusiveAccess",
|
||||
SUM(CASE WHEN "category" = 'chapter' THEN 1 ELSE 0 END) as "categorySumChapter",
|
||||
`;
|
||||
}
|
||||
if (fetchActionTypeStats) {
|
||||
@@ -29,15 +30,16 @@ async function dbGetUserSummary(userID: HashedUserID, fetchCategoryStats: boolea
|
||||
SUM(CASE WHEN "actionType" = 'mute' THEN 1 ELSE 0 END) as "typeSumMute",
|
||||
SUM(CASE WHEN "actionType" = 'full' THEN 1 ELSE 0 END) as "typeSumFull",
|
||||
SUM(CASE WHEN "actionType" = 'poi' THEN 1 ELSE 0 END) as "typeSumPoi",
|
||||
SUM(CASE WHEN "actionType" = 'chapter' THEN 1 ELSE 0 END) as "typeSumChapter",
|
||||
`;
|
||||
}
|
||||
try {
|
||||
const row = await db.prepare("get", `
|
||||
SELECT SUM(((CASE WHEN "endTime" - "startTime" > ? THEN ? ELSE "endTime" - "startTime" END) / 60) * "views") as "minutesSaved",
|
||||
SELECT SUM(CASE WHEN "actionType" = 'chapter' THEN 0 ELSE ((CASE WHEN "endTime" - "startTime" > ? THEN ? ELSE "endTime" - "startTime" END) / 60) * "views" END) as "minutesSaved",
|
||||
${additionalQuery}
|
||||
count(*) as "segmentCount"
|
||||
FROM "sponsorTimes"
|
||||
WHERE "userID" = ? AND "votes" > -2 AND "shadowHidden" !=1`,
|
||||
WHERE "userID" = ? AND "votes" > -2 AND "shadowHidden" != 1`,
|
||||
[maxRewardTimePerSegmentInSeconds, maxRewardTimePerSegmentInSeconds, userID]);
|
||||
const source = (row.minutesSaved != null) ? row : {};
|
||||
const handler = { get: (target: Record<string, any>, name: string) => target?.[name] || 0 };
|
||||
@@ -60,6 +62,7 @@ async function dbGetUserSummary(userID: HashedUserID, fetchCategoryStats: boolea
|
||||
poi_highlight: proxy.categorySumHighlight,
|
||||
filler: proxy.categorySumFiller,
|
||||
exclusive_access: proxy.categorySumExclusiveAccess,
|
||||
chapter: proxy.categorySumChapter,
|
||||
};
|
||||
}
|
||||
if (fetchActionTypeStats) {
|
||||
@@ -67,7 +70,8 @@ async function dbGetUserSummary(userID: HashedUserID, fetchCategoryStats: boolea
|
||||
skip: proxy.typeSumSkip,
|
||||
mute: proxy.typeSumMute,
|
||||
full: proxy.typeSumFull,
|
||||
poi: proxy.typeSumPoi
|
||||
poi: proxy.typeSumPoi,
|
||||
chapter: proxy.typeSumChapter,
|
||||
};
|
||||
}
|
||||
return result;
|
||||
|
||||
@@ -15,7 +15,7 @@ export async function getUsername(req: Request, res: Response): Promise<Response
|
||||
userID = await getHashCache(userID);
|
||||
|
||||
try {
|
||||
const row = await db.prepare("get", `SELECT "userName" FROM "userNames" WHERE "userID" = ?`, [userID]);
|
||||
const row = await db.prepare("get", `SELECT "userName" FROM "userNames" WHERE "userID" = ?`, [userID], { useReplica: true });
|
||||
|
||||
if (row !== undefined) {
|
||||
return res.send({
|
||||
|
||||
@@ -15,7 +15,7 @@ export async function getViewsForUser(req: Request, res: Response): Promise<Resp
|
||||
userID = await getHashCache(userID);
|
||||
|
||||
try {
|
||||
const row = await db.prepare("get", `SELECT SUM("views") as "viewCount" FROM "sponsorTimes" WHERE "userID" = ?`, [userID]);
|
||||
const row = await db.prepare("get", `SELECT SUM("views") as "viewCount" FROM "sponsorTimes" WHERE "userID" = ?`, [userID], { useReplica: true });
|
||||
|
||||
//increase the view count by one
|
||||
if (row.viewCount != null) {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { config } from "../config";
|
||||
import { Logger } from "../utils/logger";
|
||||
import { db, privateDB } from "../databases/databases";
|
||||
import { getMaxResThumbnail, YouTubeAPI } from "../utils/youtubeApi";
|
||||
import { getMaxResThumbnail } from "../utils/youtubeApi";
|
||||
import { getSubmissionUUID } from "../utils/getSubmissionUUID";
|
||||
import { getHash } from "../utils/getHash";
|
||||
import { getHashCache } from "../utils/getHashCache";
|
||||
@@ -13,7 +13,6 @@ import { ActionType, Category, IncomingSegment, IPAddress, SegmentUUID, Service,
|
||||
import { deleteLockCategories } from "./deleteLockCategories";
|
||||
import { QueryCacher } from "../utils/queryCacher";
|
||||
import { getReputation } from "../utils/reputation";
|
||||
import { APIVideoData, APIVideoInfo } from "../types/youtubeApi.model";
|
||||
import { HashedUserID, UserID } from "../types/user.model";
|
||||
import { isUserVIP } from "../utils/isUserVIP";
|
||||
import { isUserTempVIP } from "../utils/isUserTempVIP";
|
||||
@@ -21,6 +20,8 @@ import { parseUserAgent } from "../utils/userAgent";
|
||||
import { getService } from "../utils/getService";
|
||||
import axios from "axios";
|
||||
import { vote } from "./voteOnSponsorTime";
|
||||
import { canSubmit } from "../utils/permissions";
|
||||
import { getVideoDetails, videoDetails } from "../utils/getVideoDetails";
|
||||
|
||||
type CheckResult = {
|
||||
pass: boolean,
|
||||
@@ -34,7 +35,7 @@ const CHECK_PASS: CheckResult = {
|
||||
errorCode: 0
|
||||
};
|
||||
|
||||
async function sendWebhookNotification(userID: string, videoID: string, UUID: string, submissionCount: number, youtubeData: APIVideoData, { submissionStart, submissionEnd }: { submissionStart: number; submissionEnd: number; }, segmentInfo: any) {
|
||||
async function sendWebhookNotification(userID: string, videoID: string, UUID: string, submissionCount: number, youtubeData: videoDetails, { submissionStart, submissionEnd }: { submissionStart: number; submissionEnd: number; }, segmentInfo: any) {
|
||||
const row = await db.prepare("get", `SELECT "userName" FROM "userNames" WHERE "userID" = ?`, [userID]);
|
||||
const userName = row !== undefined ? row.userName : null;
|
||||
|
||||
@@ -47,7 +48,7 @@ async function sendWebhookNotification(userID: string, videoID: string, UUID: st
|
||||
"video": {
|
||||
"id": videoID,
|
||||
"title": youtubeData?.title,
|
||||
"thumbnail": getMaxResThumbnail(youtubeData) || null,
|
||||
"thumbnail": getMaxResThumbnail(videoID),
|
||||
"url": `https://www.youtube.com/watch?v=${videoID}`,
|
||||
},
|
||||
"submission": {
|
||||
@@ -63,19 +64,16 @@ async function sendWebhookNotification(userID: string, videoID: string, UUID: st
|
||||
});
|
||||
}
|
||||
|
||||
async function sendWebhooks(apiVideoInfo: APIVideoInfo, userID: string, videoID: string, UUID: string, segmentInfo: any, service: Service) {
|
||||
if (apiVideoInfo && service == Service.YouTube) {
|
||||
async function sendWebhooks(apiVideoDetails: videoDetails, userID: string, videoID: string, UUID: string, segmentInfo: any, service: Service) {
|
||||
if (apiVideoDetails && service == Service.YouTube) {
|
||||
const userSubmissionCountRow = await db.prepare("get", `SELECT count(*) as "submissionCount" FROM "sponsorTimes" WHERE "userID" = ?`, [userID]);
|
||||
|
||||
const { data, err } = apiVideoInfo;
|
||||
if (err) return;
|
||||
|
||||
const startTime = parseFloat(segmentInfo.segment[0]);
|
||||
const endTime = parseFloat(segmentInfo.segment[1]);
|
||||
sendWebhookNotification(userID, videoID, UUID, userSubmissionCountRow.submissionCount, data, {
|
||||
sendWebhookNotification(userID, videoID, UUID, userSubmissionCountRow.submissionCount, apiVideoDetails, {
|
||||
submissionStart: startTime,
|
||||
submissionEnd: endTime,
|
||||
}, segmentInfo);
|
||||
}, segmentInfo).catch(Logger.error);
|
||||
|
||||
// If it is a first time submission
|
||||
// Then send a notification to discord
|
||||
@@ -83,7 +81,7 @@ async function sendWebhooks(apiVideoInfo: APIVideoInfo, userID: string, videoID:
|
||||
|
||||
axios.post(config.discordFirstTimeSubmissionsWebhookURL, {
|
||||
embeds: [{
|
||||
title: data?.title,
|
||||
title: apiVideoDetails.title,
|
||||
url: `https://www.youtube.com/watch?v=${videoID}&t=${(parseInt(startTime.toFixed(0)) - 2)}s#requiredSegment=${UUID}`,
|
||||
description: `Submission ID: ${UUID}\
|
||||
\n\nTimestamp: \
|
||||
@@ -94,7 +92,7 @@ async function sendWebhooks(apiVideoInfo: APIVideoInfo, userID: string, videoID:
|
||||
name: userID,
|
||||
},
|
||||
thumbnail: {
|
||||
url: getMaxResThumbnail(data) || "",
|
||||
url: getMaxResThumbnail(videoID),
|
||||
},
|
||||
}],
|
||||
})
|
||||
@@ -119,18 +117,10 @@ async function sendWebhooks(apiVideoInfo: APIVideoInfo, userID: string, videoID:
|
||||
// Looks like this was broken for no defined youtube key - fixed but IMO we shouldn't return
|
||||
// false for a pass - it was confusing and lead to this bug - any use of this function in
|
||||
// the future could have the same problem.
|
||||
async function autoModerateSubmission(apiVideoInfo: APIVideoInfo,
|
||||
async function autoModerateSubmission(apiVideoDetails: videoDetails,
|
||||
submission: { videoID: VideoID; userID: UserID; segments: IncomingSegment[], service: Service, videoDuration: number }) {
|
||||
|
||||
const apiVideoDuration = (apiVideoInfo: APIVideoInfo) => {
|
||||
if (!apiVideoInfo) return undefined;
|
||||
const { err, data } = apiVideoInfo;
|
||||
// return undefined if API error
|
||||
if (err) return undefined;
|
||||
return data?.lengthSeconds;
|
||||
};
|
||||
// get duration from API
|
||||
const apiDuration = apiVideoDuration(apiVideoInfo);
|
||||
const apiDuration = apiVideoDetails.duration;
|
||||
// if API fail or returns 0, get duration from client
|
||||
const duration = apiDuration || submission.videoDuration;
|
||||
// return false on undefined or 0
|
||||
@@ -138,14 +128,16 @@ async function autoModerateSubmission(apiVideoInfo: APIVideoInfo,
|
||||
|
||||
const segments = submission.segments;
|
||||
// map all times to float array
|
||||
const allSegmentTimes = segments.map(segment => [parseFloat(segment.segment[0]), parseFloat(segment.segment[1])]);
|
||||
const allSegmentTimes = segments.filter((s) => s.actionType !== ActionType.Chapter)
|
||||
.map(segment => [parseFloat(segment.segment[0]), parseFloat(segment.segment[1])]);
|
||||
|
||||
// add previous submissions by this user
|
||||
const allSubmittedByUser = await db.prepare("all", `SELECT "startTime", "endTime" FROM "sponsorTimes" WHERE "userID" = ? AND "videoID" = ? AND "votes" > -1 AND "hidden" = 0`, [submission.userID, submission.videoID]);
|
||||
const allSubmittedByUser = await db.prepare("all", `SELECT "startTime", "endTime" FROM "sponsorTimes" WHERE "userID" = ? AND "videoID" = ? AND "votes" > -1 AND "actionType" != 'chapter' AND "hidden" = 0`
|
||||
, [submission.userID, submission.videoID]) as { startTime: string, endTime: string }[];
|
||||
|
||||
if (allSubmittedByUser) {
|
||||
//add segments the user has previously submitted
|
||||
const allSubmittedTimes = allSubmittedByUser.map((segment: { startTime: string, endTime: string }) => [parseFloat(segment.startTime), parseFloat(segment.endTime)]);
|
||||
const allSubmittedTimes = allSubmittedByUser.map((segment) => [parseFloat(segment.startTime), parseFloat(segment.endTime)]);
|
||||
allSegmentTimes.push(...allSubmittedTimes);
|
||||
}
|
||||
|
||||
@@ -162,14 +154,6 @@ async function autoModerateSubmission(apiVideoInfo: APIVideoInfo,
|
||||
return false;
|
||||
}
|
||||
|
||||
function getYouTubeVideoInfo(videoID: VideoID, ignoreCache = false): Promise<APIVideoInfo> {
|
||||
if (config.newLeafURLs !== null) {
|
||||
return YouTubeAPI.listVideos(videoID, ignoreCache);
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function checkUserActiveWarning(userID: string): Promise<CheckResult> {
|
||||
const MILLISECONDS_IN_HOUR = 3600000;
|
||||
const now = Date.now();
|
||||
@@ -200,7 +184,8 @@ async function checkUserActiveWarning(userID: string): Promise<CheckResult> {
|
||||
return CHECK_PASS;
|
||||
}
|
||||
|
||||
function checkInvalidFields(videoID: VideoID, userID: UserID, segments: IncomingSegment[]): CheckResult {
|
||||
async function checkInvalidFields(videoID: VideoID, userID: UserID, hashedUserID: HashedUserID
|
||||
, segments: IncomingSegment[], videoDurationParam: number, userAgent: string): Promise<CheckResult> {
|
||||
const invalidFields = [];
|
||||
const errors = [];
|
||||
if (typeof videoID !== "string" || videoID?.length == 0) {
|
||||
@@ -223,10 +208,20 @@ function checkInvalidFields(videoID: VideoID, userID: UserID, segments: Incoming
|
||||
}
|
||||
|
||||
if (typeof segmentPair.description !== "string"
|
||||
|| (segmentPair.actionType === ActionType.Chapter && segmentPair.description.length > 60 )
|
||||
|| (segmentPair.description.length !== 0 && segmentPair.actionType !== ActionType.Chapter)) {
|
||||
invalidFields.push("segment description");
|
||||
}
|
||||
|
||||
if (segmentPair.actionType === ActionType.Chapter && segmentPair.description.length > 200) {
|
||||
invalidFields.push("chapter name (too long)");
|
||||
}
|
||||
|
||||
const permission = await canSubmit(hashedUserID, segmentPair.category);
|
||||
if (!permission.canSubmit) {
|
||||
Logger.warn(`Rejecting submission due to lack of permissions for category ${segmentPair.category}: ${segmentPair.segment} ${hashedUserID} ${videoID} ${videoDurationParam} ${userAgent}`);
|
||||
invalidFields.push(`permission to submit ${segmentPair.category}`);
|
||||
errors.push(permission.reason);
|
||||
}
|
||||
}
|
||||
|
||||
if (invalidFields.length !== 0) {
|
||||
@@ -235,7 +230,7 @@ function checkInvalidFields(videoID: VideoID, userID: UserID, segments: Incoming
|
||||
const formattedErrors = errors.reduce((p, c, i) => p + (i !== 0 ? ". " : " ") + c, "");
|
||||
return {
|
||||
pass: false,
|
||||
errorMessage: `No valid ${formattedFields} field(s) provided.${formattedErrors}`,
|
||||
errorMessage: `No valid ${formattedFields}.${formattedErrors}`,
|
||||
errorCode: 400
|
||||
};
|
||||
}
|
||||
@@ -244,7 +239,7 @@ function checkInvalidFields(videoID: VideoID, userID: UserID, segments: Incoming
|
||||
}
|
||||
|
||||
async function checkEachSegmentValid(rawIP: IPAddress, paramUserID: UserID, userID: HashedUserID, videoID: VideoID,
|
||||
segments: IncomingSegment[], service: string, isVIP: boolean, lockedCategoryList: Array<any>): Promise<CheckResult> {
|
||||
segments: IncomingSegment[], service: Service, isVIP: boolean, lockedCategoryList: Array<any>): Promise<CheckResult> {
|
||||
|
||||
for (let i = 0; i < segments.length; i++) {
|
||||
if (segments[i] === undefined || segments[i].segment === undefined || segments[i].category === undefined) {
|
||||
@@ -259,7 +254,13 @@ async function checkEachSegmentValid(rawIP: IPAddress, paramUserID: UserID, user
|
||||
// Reject segment if it's in the locked categories list
|
||||
const lockIndex = lockedCategoryList.findIndex(c => segments[i].category === c.category && segments[i].actionType === c.actionType);
|
||||
if (!isVIP && lockIndex !== -1) {
|
||||
// TODO: Do something about the fradulent submission
|
||||
QueryCacher.clearSegmentCache({
|
||||
videoID,
|
||||
hashedVideoID: await getHashCache(videoID, 1),
|
||||
service,
|
||||
userID
|
||||
});
|
||||
|
||||
Logger.warn(`Caught a submission for a locked category. userID: '${userID}', videoID: '${videoID}', category: '${segments[i].category}', times: ${segments[i].segment}`);
|
||||
return {
|
||||
pass: false,
|
||||
@@ -325,10 +326,10 @@ async function checkEachSegmentValid(rawIP: IPAddress, paramUserID: UserID, user
|
||||
return CHECK_PASS;
|
||||
}
|
||||
|
||||
async function checkByAutoModerator(videoID: any, userID: any, segments: Array<any>, service:string, apiVideoInfo: APIVideoInfo, videoDuration: number): Promise<CheckResult> {
|
||||
async function checkByAutoModerator(videoID: any, userID: any, segments: Array<any>, service:string, apiVideoDetails: videoDetails, videoDuration: number): Promise<CheckResult> {
|
||||
// Auto moderator check
|
||||
if (service == Service.YouTube) {
|
||||
const autoModerateResult = await autoModerateSubmission(apiVideoInfo, { userID, videoID, segments, service, videoDuration });
|
||||
const autoModerateResult = await autoModerateSubmission(apiVideoDetails, { userID, videoID, segments, service, videoDuration });
|
||||
if (autoModerateResult) {
|
||||
return {
|
||||
pass: false,
|
||||
@@ -357,12 +358,13 @@ async function updateDataIfVideoDurationChange(videoID: VideoID, service: Servic
|
||||
const videoDurationChanged = (videoDuration: number) => videoDuration != 0
|
||||
&& previousSubmissions.length > 0 && !previousSubmissions.some((e) => Math.abs(videoDuration - e.videoDuration) < 2);
|
||||
|
||||
let apiVideoInfo: APIVideoInfo = null;
|
||||
let apiVideoDetails: videoDetails = null;
|
||||
if (service == Service.YouTube) {
|
||||
// Don't use cache if we don't know the video duration, or the client claims that it has changed
|
||||
apiVideoInfo = await getYouTubeVideoInfo(videoID, !videoDurationParam || previousSubmissions.length === 0 || videoDurationChanged(videoDurationParam));
|
||||
const ignoreCache = !videoDurationParam || previousSubmissions.length === 0 || videoDurationChanged(videoDurationParam);
|
||||
apiVideoDetails = await getVideoDetails(videoID, ignoreCache);
|
||||
}
|
||||
const apiVideoDuration = apiVideoInfo?.data?.lengthSeconds as VideoDuration;
|
||||
const apiVideoDuration = apiVideoDetails?.duration as VideoDuration;
|
||||
if (!videoDurationParam || (apiVideoDuration && Math.abs(videoDurationParam - apiVideoDuration) > 2)) {
|
||||
// If api duration is far off, take that one instead (it is only precise to seconds, not millis)
|
||||
videoDuration = apiVideoDuration || 0 as VideoDuration;
|
||||
@@ -375,12 +377,12 @@ async function updateDataIfVideoDurationChange(videoID: VideoID, service: Servic
|
||||
await db.prepare("run", `UPDATE "sponsorTimes" SET "hidden" = 1 WHERE "UUID" = ?`, [submission.UUID]);
|
||||
}
|
||||
lockedCategoryList = [];
|
||||
deleteLockCategories(videoID, null, null, service);
|
||||
deleteLockCategories(videoID, null, null, service).catch(Logger.error);
|
||||
}
|
||||
|
||||
return {
|
||||
videoDuration,
|
||||
apiVideoInfo,
|
||||
apiVideoDetails,
|
||||
lockedCategoryList
|
||||
};
|
||||
}
|
||||
@@ -478,27 +480,26 @@ export async function postSkipSegments(req: Request, res: Response): Promise<Res
|
||||
// eslint-disable-next-line prefer-const
|
||||
let { videoID, userID: paramUserID, service, videoDuration, videoDurationParam, segments, userAgent } = preprocessInput(req);
|
||||
|
||||
const invalidCheckResult = checkInvalidFields(videoID, paramUserID, segments);
|
||||
//hash the userID
|
||||
const userID = await getHashCache(paramUserID || "");
|
||||
|
||||
const invalidCheckResult = await checkInvalidFields(videoID, paramUserID, userID, segments, videoDurationParam, userAgent);
|
||||
if (!invalidCheckResult.pass) {
|
||||
return res.status(invalidCheckResult.errorCode).send(invalidCheckResult.errorMessage);
|
||||
}
|
||||
|
||||
//hash the userID
|
||||
const userID = await getHashCache(paramUserID);
|
||||
|
||||
const userWarningCheckResult = await checkUserActiveWarning(userID);
|
||||
if (!userWarningCheckResult.pass) {
|
||||
Logger.warn(`Caught a submission for a warned user. userID: '${userID}', videoID: '${videoID}', category: '${segments.reduce<string>((prev, val) => `${prev} ${val.category}`, "")}', times: ${segments.reduce<string>((prev, val) => `${prev} ${val.segment}`, "")}`);
|
||||
return res.status(userWarningCheckResult.errorCode).send(userWarningCheckResult.errorMessage);
|
||||
}
|
||||
|
||||
const isVIP = await isUserVIP(userID);
|
||||
const isTempVIP = await isUserTempVIP(userID, videoID);
|
||||
const isVIP = (await isUserVIP(userID)) || (await isUserTempVIP(userID, videoID));
|
||||
const rawIP = getIP(req);
|
||||
|
||||
const newData = await updateDataIfVideoDurationChange(videoID, service, videoDuration, videoDurationParam);
|
||||
videoDuration = newData.videoDuration;
|
||||
const { lockedCategoryList, apiVideoInfo } = newData;
|
||||
const { lockedCategoryList, apiVideoDetails } = newData;
|
||||
|
||||
// Check if all submissions are correct
|
||||
const segmentCheckResult = await checkEachSegmentValid(rawIP, paramUserID, userID, videoID, segments, service, isVIP, lockedCategoryList);
|
||||
@@ -506,8 +507,8 @@ export async function postSkipSegments(req: Request, res: Response): Promise<Res
|
||||
return res.status(segmentCheckResult.errorCode).send(segmentCheckResult.errorMessage);
|
||||
}
|
||||
|
||||
if (!isVIP && !isTempVIP) {
|
||||
const autoModerateCheckResult = await checkByAutoModerator(videoID, userID, segments, service, apiVideoInfo, videoDurationParam);
|
||||
if (!isVIP) {
|
||||
const autoModerateCheckResult = await checkByAutoModerator(videoID, userID, segments, service, apiVideoDetails, videoDurationParam);
|
||||
if (!autoModerateCheckResult.pass) {
|
||||
return res.status(autoModerateCheckResult.errorCode).send(autoModerateCheckResult.errorMessage);
|
||||
}
|
||||
@@ -560,10 +561,10 @@ export async function postSkipSegments(req: Request, res: Response): Promise<Res
|
||||
//add to private db as well
|
||||
await privateDB.prepare("run", `INSERT INTO "sponsorTimes" VALUES(?, ?, ?, ?)`, [videoID, hashedIP, timeSubmitted, service]);
|
||||
|
||||
await db.prepare("run", `INSERT INTO "videoInfo" ("videoID", "channelID", "title", "published", "genreUrl")
|
||||
SELECT ?, ?, ?, ?, ?
|
||||
await db.prepare("run", `INSERT INTO "videoInfo" ("videoID", "channelID", "title", "published")
|
||||
SELECT ?, ?, ?, ?
|
||||
WHERE NOT EXISTS (SELECT 1 FROM "videoInfo" WHERE "videoID" = ?)`, [
|
||||
videoID, apiVideoInfo?.data?.authorId || "", apiVideoInfo?.data?.title || "", apiVideoInfo?.data?.published || 0, apiVideoInfo?.data?.genreUrl || "", videoID]);
|
||||
videoID, apiVideoDetails?.authorId || "", apiVideoDetails?.title || "", apiVideoDetails?.published || 0, videoID]);
|
||||
|
||||
// Clear redis cache for this video
|
||||
QueryCacher.clearSegmentCache({
|
||||
@@ -591,7 +592,7 @@ export async function postSkipSegments(req: Request, res: Response): Promise<Res
|
||||
}
|
||||
|
||||
for (let i = 0; i < segments.length; i++) {
|
||||
sendWebhooks(apiVideoInfo, userID, videoID, UUIDs[i], segments[i], service);
|
||||
sendWebhooks(apiVideoDetails, userID, videoID, UUIDs[i], segments[i], service).catch(Logger.error);
|
||||
}
|
||||
return res.json(newSegments);
|
||||
}
|
||||
|
||||
@@ -22,17 +22,15 @@ function checkExpiredWarning(warning: warningEntry): boolean {
|
||||
}
|
||||
|
||||
export async function postWarning(req: Request, res: Response): Promise<Response> {
|
||||
// exit early if no body passed in
|
||||
if (!req.body.userID && !req.body.issuerUserID) return res.status(400).json({ "message": "Missing parameters" });
|
||||
// Collect user input data
|
||||
const issuerUserID: HashedUserID = await getHashCache(<UserID> req.body.issuerUserID);
|
||||
const userID: UserID = req.body.userID;
|
||||
if (!req.body.userID) return res.status(400).json({ "message": "Missing parameters" });
|
||||
|
||||
const issuerUserID: HashedUserID = req.body.issuerUserID ? await getHashCache(req.body.issuerUserID as UserID) : null;
|
||||
const userID: HashedUserID = issuerUserID ? req.body.userID : await getHashCache(req.body.userID as UserID);
|
||||
const issueTime = new Date().getTime();
|
||||
const enabled: boolean = req.body.enabled ?? true;
|
||||
const reason: string = req.body.reason ?? "";
|
||||
|
||||
// Ensure user is a VIP
|
||||
if (!await isUserVIP(issuerUserID)) {
|
||||
if ((!issuerUserID && enabled) || (issuerUserID && !await isUserVIP(issuerUserID))) {
|
||||
Logger.warn(`Permission violation: User ${issuerUserID} attempted to warn user ${userID}.`);
|
||||
return res.status(403).json({ "message": "Not a VIP" });
|
||||
}
|
||||
@@ -52,8 +50,8 @@ export async function postWarning(req: Request, res: Response): Promise<Response
|
||||
// check if warning is still within issue time and warning is not enabled
|
||||
} else if (checkExpiredWarning(previousWarning) ) {
|
||||
await db.prepare(
|
||||
"run", 'UPDATE "warnings" SET "enabled" = 1 WHERE "userID" = ? AND "issueTime" = ?',
|
||||
[userID, previousWarning.issueTime]
|
||||
"run", 'UPDATE "warnings" SET "enabled" = 1, "reason" = ? WHERE "userID" = ? AND "issueTime" = ?',
|
||||
[reason, userID, previousWarning.issueTime]
|
||||
);
|
||||
resultStatus = "re-enabled";
|
||||
} else {
|
||||
|
||||
@@ -1,91 +0,0 @@
|
||||
import { Request, Response } from "express";
|
||||
import { db } from "../../databases/databases";
|
||||
import { RatingType } from "../../types/ratings.model";
|
||||
import { Service, VideoID, VideoIDHash } from "../../types/segments.model";
|
||||
import { getService } from "../../utils/getService";
|
||||
import { hashPrefixTester } from "../../utils/hashPrefixTester";
|
||||
import { Logger } from "../../utils/logger";
|
||||
import { QueryCacher } from "../../utils/queryCacher";
|
||||
import { ratingHashKey } from "../../utils/redisKeys";
|
||||
|
||||
interface DBRating {
|
||||
videoID: VideoID,
|
||||
hashedVideoID: VideoIDHash,
|
||||
service: Service,
|
||||
type: RatingType,
|
||||
count: number
|
||||
}
|
||||
|
||||
export async function getRating(req: Request, res: Response): Promise<Response> {
|
||||
let hashPrefixes: VideoIDHash[] = [];
|
||||
try {
|
||||
hashPrefixes = req.query.hashPrefixes
|
||||
? JSON.parse(req.query.hashPrefixes as string)
|
||||
: Array.isArray(req.query.prefix)
|
||||
? req.query.prefix
|
||||
: [req.query.prefix ?? req.params.prefix];
|
||||
if (!Array.isArray(hashPrefixes)) {
|
||||
return res.status(400).send("hashPrefixes parameter does not match format requirements.");
|
||||
}
|
||||
|
||||
hashPrefixes.map((hashPrefix) => hashPrefix?.toLowerCase());
|
||||
} catch(error) {
|
||||
return res.status(400).send("Bad parameter: hashPrefixes (invalid JSON)");
|
||||
}
|
||||
if (hashPrefixes.length === 0 || hashPrefixes.length > 75
|
||||
|| hashPrefixes.some((hashPrefix) => !hashPrefix || !hashPrefixTester(hashPrefix))) {
|
||||
return res.status(400).send("Hash prefix does not match format requirements."); // Exit early on faulty prefix
|
||||
}
|
||||
|
||||
let types: RatingType[] = [];
|
||||
try {
|
||||
types = req.query.types
|
||||
? JSON.parse(req.query.types as string)
|
||||
: req.query.type
|
||||
? Array.isArray(req.query.type)
|
||||
? req.query.type
|
||||
: [req.query.type]
|
||||
: [RatingType.Upvote, RatingType.Downvote];
|
||||
if (!Array.isArray(types)) {
|
||||
return res.status(400).send("Types parameter does not match format requirements.");
|
||||
}
|
||||
|
||||
types = types.map((type) => parseInt(type as unknown as string, 10));
|
||||
} catch(error) {
|
||||
return res.status(400).send("Bad parameter: types (invalid JSON)");
|
||||
}
|
||||
|
||||
const service: Service = getService(req.query.service, req.body.service);
|
||||
|
||||
try {
|
||||
const ratings = (await getRatings(hashPrefixes, service))
|
||||
.filter((rating) => types.includes(rating.type))
|
||||
.map((rating) => ({
|
||||
videoID: rating.videoID,
|
||||
hash: rating.hashedVideoID,
|
||||
service: rating.service,
|
||||
type: rating.type,
|
||||
count: rating.count
|
||||
}));
|
||||
|
||||
return res.status((ratings.length) ? 200 : 404)
|
||||
.send(ratings ?? []);
|
||||
} catch (err) {
|
||||
Logger.error(err as string);
|
||||
|
||||
return res.sendStatus(500);
|
||||
}
|
||||
}
|
||||
|
||||
function getRatings(hashPrefixes: VideoIDHash[], service: Service): Promise<DBRating[]> {
|
||||
const fetchFromDB = (hashPrefixes: VideoIDHash[]) => db
|
||||
.prepare(
|
||||
"all",
|
||||
`SELECT "videoID", "hashedVideoID", "type", "count" FROM "ratings" WHERE "hashedVideoID" ~* ? AND "service" = ? ORDER BY "hashedVideoID"`,
|
||||
[`^(?:${hashPrefixes.join("|")})`, service]
|
||||
) as Promise<DBRating[]>;
|
||||
|
||||
return (hashPrefixes.every((hashPrefix) => hashPrefix.length === 4))
|
||||
? QueryCacher.getAndSplit(fetchFromDB, (prefix) => ratingHashKey(prefix, service), "hashedVideoID", hashPrefixes)
|
||||
: fetchFromDB(hashPrefixes);
|
||||
}
|
||||
@@ -1,53 +0,0 @@
|
||||
import { Logger } from "../../utils/logger";
|
||||
import { HashedUserID, UserID } from "../../types/user.model";
|
||||
import { getHash } from "../../utils/getHash";
|
||||
import { getHashCache } from "../../utils/getHashCache";
|
||||
import { Request, Response } from "express";
|
||||
import { Service, VideoID } from "../../types/segments.model";
|
||||
import { QueryCacher } from "../../utils/queryCacher";
|
||||
import { isUserVIP } from "../../utils/isUserVIP";
|
||||
import { VideoIDHash } from "../../types/segments.model";
|
||||
import { getService } from "../..//utils/getService";
|
||||
|
||||
export async function postClearCache(req: Request, res: Response): Promise<Response> {
|
||||
const videoID = req.query.videoID as VideoID;
|
||||
const userID = req.query.userID as UserID;
|
||||
const service = getService(req.query.service as Service);
|
||||
|
||||
const invalidFields = [];
|
||||
if (typeof videoID !== "string") {
|
||||
invalidFields.push("videoID");
|
||||
}
|
||||
if (typeof userID !== "string") {
|
||||
invalidFields.push("userID");
|
||||
}
|
||||
|
||||
if (invalidFields.length !== 0) {
|
||||
// invalid request
|
||||
const fields = invalidFields.reduce((p, c, i) => p + (i !== 0 ? ", " : "") + c, "");
|
||||
return res.status(400).send(`No valid ${fields} field(s) provided`);
|
||||
}
|
||||
|
||||
// hash the userID as early as possible
|
||||
const hashedUserID: HashedUserID = await getHashCache(userID);
|
||||
// hash videoID
|
||||
const hashedVideoID: VideoIDHash = getHash(videoID, 1);
|
||||
|
||||
// Ensure user is a VIP
|
||||
if (!(await isUserVIP(hashedUserID))){
|
||||
Logger.warn(`Permission violation: User ${hashedUserID} attempted to clear cache for video ${videoID}.`);
|
||||
return res.status(403).json({ "message": "Not a VIP" });
|
||||
}
|
||||
|
||||
try {
|
||||
QueryCacher.clearRatingCache({
|
||||
hashedVideoID,
|
||||
service
|
||||
});
|
||||
return res.status(200).json({
|
||||
message: `Cache cleared on video ${videoID}`
|
||||
});
|
||||
} catch(err) {
|
||||
return res.sendStatus(500);
|
||||
}
|
||||
}
|
||||
@@ -1,65 +0,0 @@
|
||||
import { db, privateDB } from "../../databases/databases";
|
||||
import { getHash } from "../../utils/getHash";
|
||||
import { getHashCache } from "../../utils/getHashCache";
|
||||
import { Logger } from "../../utils/logger";
|
||||
import { Request, Response } from "express";
|
||||
import { HashedUserID, UserID } from "../../types/user.model";
|
||||
import { HashedIP, IPAddress, VideoID } from "../../types/segments.model";
|
||||
import { getIP } from "../../utils/getIP";
|
||||
import { getService } from "../../utils/getService";
|
||||
import { RatingType, RatingTypes } from "../../types/ratings.model";
|
||||
import { config } from "../../config";
|
||||
import { QueryCacher } from "../../utils/queryCacher";
|
||||
|
||||
export async function postRating(req: Request, res: Response): Promise<Response> {
|
||||
const privateUserID = req.body.userID as UserID;
|
||||
const videoID = req.body.videoID as VideoID;
|
||||
const service = getService(req.query.service, req.body.service);
|
||||
const type = req.body.type as RatingType;
|
||||
const enabled = req.body.enabled ?? true;
|
||||
|
||||
if (privateUserID == undefined || videoID == undefined || service == undefined || type == undefined
|
||||
|| (typeof privateUserID !== "string") || (typeof videoID !== "string") || (typeof service !== "string")
|
||||
|| (typeof type !== "number") || (enabled && (typeof enabled !== "boolean")) || !RatingTypes.includes(type)) {
|
||||
//invalid request
|
||||
return res.sendStatus(400);
|
||||
}
|
||||
|
||||
const hashedIP: HashedIP = getHash(getIP(req) + config.globalSalt as IPAddress, 1);
|
||||
const hashedUserID: HashedUserID = await getHashCache(privateUserID);
|
||||
const hashedVideoID = getHash(videoID, 1);
|
||||
|
||||
try {
|
||||
// Check if this user has voted before
|
||||
const existingVote = await privateDB.prepare("get", `SELECT count(*) as "count" FROM "ratings" WHERE "videoID" = ? AND "service" = ? AND "type" = ? AND "userID" = ?`, [videoID, service, type, hashedUserID]);
|
||||
if (existingVote.count > 0 && !enabled) {
|
||||
// Undo the vote
|
||||
await privateDB.prepare("run", `DELETE FROM "ratings" WHERE "videoID" = ? AND "service" = ? AND "type" = ? AND "userID" = ?`, [videoID, service, type, hashedUserID]);
|
||||
await db.prepare("run", `UPDATE "ratings" SET "count" = "count" - 1 WHERE "videoID" = ? AND "service" = ? AND type = ?`, [videoID, service, type]);
|
||||
} else if (existingVote.count === 0 && enabled) {
|
||||
// Make sure there hasn't been another vote from this IP
|
||||
const existingIPVote = (await privateDB.prepare("get", `SELECT count(*) as "count" FROM "ratings" WHERE "videoID" = ? AND "service" = ? AND "type" = ? AND "hashedIP" = ?`, [videoID, service, type, hashedIP]))
|
||||
.count > 0;
|
||||
if (existingIPVote) { // if exisiting vote, exit early instead
|
||||
return res.sendStatus(200);
|
||||
}
|
||||
|
||||
// Create entry in privateDB
|
||||
await privateDB.prepare("run", `INSERT INTO "ratings" ("videoID", "service", "type", "userID", "timeSubmitted", "hashedIP") VALUES (?, ?, ?, ?, ?, ?)`, [videoID, service, type, hashedUserID, Date.now(), hashedIP]);
|
||||
|
||||
// Check if general rating already exists, if so increase it
|
||||
const rating = await db.prepare("get", `SELECT count(*) as "count" FROM "ratings" WHERE "videoID" = ? AND "service" = ? AND type = ?`, [videoID, service, type]);
|
||||
if (rating.count > 0) {
|
||||
await db.prepare("run", `UPDATE "ratings" SET "count" = "count" + 1 WHERE "videoID" = ? AND "service" = ? AND type = ?`, [videoID, service, type]);
|
||||
} else {
|
||||
await db.prepare("run", `INSERT INTO "ratings" ("videoID", "service", "type", "count", "hashedVideoID") VALUES (?, ?, ?, 1, ?)`, [videoID, service, type, hashedVideoID]);
|
||||
}
|
||||
}
|
||||
// clear rating cache
|
||||
QueryCacher.clearRatingCache({ hashedVideoID, service });
|
||||
return res.sendStatus(200);
|
||||
} catch (err) {
|
||||
Logger.error(err as string);
|
||||
return res.sendStatus(500);
|
||||
}
|
||||
}
|
||||
87
src/routes/verifyToken.ts
Normal file
87
src/routes/verifyToken.ts
Normal file
@@ -0,0 +1,87 @@
|
||||
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 licenseRegex = new RegExp(/[a-zA-Z0-9]{40}|[A-Z0-9-]{35}/);
|
||||
if (!licenseRegex.test(licenseKey)) {
|
||||
return res.status(200).send({
|
||||
allowed: false
|
||||
});
|
||||
}
|
||||
|
||||
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).catch(Logger.error);
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
@@ -3,7 +3,6 @@ import { Logger } from "../utils/logger";
|
||||
import { isUserVIP } from "../utils/isUserVIP";
|
||||
import { isUserTempVIP } from "../utils/isUserTempVIP";
|
||||
import { getMaxResThumbnail, YouTubeAPI } from "../utils/youtubeApi";
|
||||
import { APIVideoInfo } from "../types/youtubeApi.model";
|
||||
import { db, privateDB } from "../databases/databases";
|
||||
import { dispatchEvent, getVoteAuthor, getVoteAuthorRaw } from "../utils/webhookUtils";
|
||||
import { getFormattedTime } from "../utils/getFormattedTime";
|
||||
@@ -11,9 +10,10 @@ import { getIP } from "../utils/getIP";
|
||||
import { getHashCache } from "../utils/getHashCache";
|
||||
import { config } from "../config";
|
||||
import { UserID } from "../types/user.model";
|
||||
import { DBSegment, Category, HashedIP, IPAddress, SegmentUUID, Service, VideoID, VideoIDHash, VideoDuration, ActionType } from "../types/segments.model";
|
||||
import { DBSegment, Category, HashedIP, IPAddress, SegmentUUID, Service, VideoID, VideoIDHash, VideoDuration, ActionType, VoteType } from "../types/segments.model";
|
||||
import { QueryCacher } from "../utils/queryCacher";
|
||||
import axios from "axios";
|
||||
import { getVideoDetails, videoDetails } from "../utils/getVideoDetails";
|
||||
|
||||
const voteTypes = {
|
||||
normal: 0,
|
||||
@@ -36,6 +36,7 @@ interface FinalResponse {
|
||||
interface VoteData {
|
||||
UUID: string;
|
||||
nonAnonUserID: string;
|
||||
originalType: VoteType;
|
||||
voteTypeEnum: number;
|
||||
isTempVIP: boolean;
|
||||
isVIP: boolean;
|
||||
@@ -51,20 +52,16 @@ interface VoteData {
|
||||
finalResponse: FinalResponse;
|
||||
}
|
||||
|
||||
function getYouTubeVideoInfo(videoID: VideoID, ignoreCache = false): Promise<APIVideoInfo> {
|
||||
return config.newLeafURLs ? YouTubeAPI.listVideos(videoID, ignoreCache) : null;
|
||||
}
|
||||
|
||||
const videoDurationChanged = (segmentDuration: number, APIDuration: number) => (APIDuration > 0 && Math.abs(segmentDuration - APIDuration) > 2);
|
||||
|
||||
async function updateSegmentVideoDuration(UUID: SegmentUUID) {
|
||||
const { videoDuration, videoID, service } = await db.prepare("get", `select "videoDuration", "videoID", "service" from "sponsorTimes" where "UUID" = ?`, [UUID]);
|
||||
let apiVideoInfo: APIVideoInfo = null;
|
||||
let apiVideoDetails: videoDetails = null;
|
||||
if (service == Service.YouTube) {
|
||||
// don't use cache since we have no information about the video length
|
||||
apiVideoInfo = await getYouTubeVideoInfo(videoID);
|
||||
apiVideoDetails = await getVideoDetails(videoID);
|
||||
}
|
||||
const apiVideoDuration = apiVideoInfo?.data?.lengthSeconds as VideoDuration;
|
||||
const apiVideoDuration = apiVideoDetails?.duration as VideoDuration;
|
||||
if (videoDurationChanged(videoDuration, apiVideoDuration)) {
|
||||
Logger.info(`Video duration changed for ${videoID} from ${videoDuration} to ${apiVideoDuration}`);
|
||||
await db.prepare("run", `UPDATE "sponsorTimes" SET "videoDuration" = ? WHERE "UUID" = ?`, [apiVideoDuration, UUID]);
|
||||
@@ -73,12 +70,12 @@ async function updateSegmentVideoDuration(UUID: SegmentUUID) {
|
||||
|
||||
async function checkVideoDuration(UUID: SegmentUUID) {
|
||||
const { videoID, service } = await db.prepare("get", `select "videoID", "service" from "sponsorTimes" where "UUID" = ?`, [UUID]);
|
||||
let apiVideoInfo: APIVideoInfo = null;
|
||||
let apiVideoDetails: videoDetails = null;
|
||||
if (service == Service.YouTube) {
|
||||
// don't use cache since we have no information about the video length
|
||||
apiVideoInfo = await getYouTubeVideoInfo(videoID, true);
|
||||
apiVideoDetails = await getVideoDetails(videoID, true);
|
||||
}
|
||||
const apiVideoDuration = apiVideoInfo?.data?.lengthSeconds as VideoDuration;
|
||||
const apiVideoDuration = apiVideoDetails?.duration as VideoDuration;
|
||||
// if no videoDuration return early
|
||||
if (isNaN(apiVideoDuration)) return;
|
||||
// fetch latest submission
|
||||
@@ -112,7 +109,9 @@ async function sendWebhooks(voteData: VoteData) {
|
||||
|
||||
if (submissionInfoRow !== undefined && userSubmissionCountRow != undefined) {
|
||||
let webhookURL: string = null;
|
||||
if (voteData.voteTypeEnum === voteTypes.normal) {
|
||||
if (voteData.originalType === VoteType.Malicious) {
|
||||
webhookURL = config.discordMaliciousReportWebhookURL;
|
||||
} else if (voteData.voteTypeEnum === voteTypes.normal) {
|
||||
switch (voteData.finalResponse.webhookType) {
|
||||
case VoteWebhookType.Normal:
|
||||
webhookURL = config.discordReportChannelWebhookURL;
|
||||
@@ -126,7 +125,8 @@ async function sendWebhooks(voteData: VoteData) {
|
||||
}
|
||||
|
||||
if (config.newLeafURLs !== null) {
|
||||
const { err, data } = await YouTubeAPI.listVideos(submissionInfoRow.videoID);
|
||||
const videoID = submissionInfoRow.videoID;
|
||||
const { err, data } = await YouTubeAPI.listVideos(videoID);
|
||||
if (err) return;
|
||||
|
||||
const isUpvote = voteData.incrementAmount > 0;
|
||||
@@ -138,8 +138,8 @@ async function sendWebhooks(voteData: VoteData) {
|
||||
"video": {
|
||||
"id": submissionInfoRow.videoID,
|
||||
"title": data?.title,
|
||||
"url": `https://www.youtube.com/watch?v=${submissionInfoRow.videoID}`,
|
||||
"thumbnail": getMaxResThumbnail(data) || null,
|
||||
"url": `https://www.youtube.com/watch?v=${videoID}`,
|
||||
"thumbnail": getMaxResThumbnail(videoID),
|
||||
},
|
||||
"submission": {
|
||||
"UUID": voteData.UUID,
|
||||
@@ -184,7 +184,7 @@ async function sendWebhooks(voteData: VoteData) {
|
||||
`${getVoteAuthor(userSubmissionCountRow.submissionCount, voteData.isTempVIP, voteData.isVIP, voteData.isOwnSubmission)}${voteData.row.locked ? " (Locked)" : ""}`,
|
||||
},
|
||||
"thumbnail": {
|
||||
"url": getMaxResThumbnail(data) || "",
|
||||
"url": getMaxResThumbnail(videoID),
|
||||
},
|
||||
}],
|
||||
})
|
||||
@@ -208,7 +208,7 @@ async function sendWebhooks(voteData: VoteData) {
|
||||
async function categoryVote(UUID: SegmentUUID, userID: UserID, isVIP: boolean, isTempVIP: boolean, isOwnSubmission: boolean, category: Category
|
||||
, hashedIP: HashedIP, finalResponse: FinalResponse): Promise<{ status: number, message?: string }> {
|
||||
// Check if they've already made a vote
|
||||
const usersLastVoteInfo = await privateDB.prepare("get", `select count(*) as votes, category from "categoryVotes" where "UUID" = ? and "userID" = ? group by category`, [UUID, userID]);
|
||||
const usersLastVoteInfo = await privateDB.prepare("get", `select count(*) as votes, category from "categoryVotes" where "UUID" = ? and "userID" = ? group by category`, [UUID, userID], { useReplica: true });
|
||||
|
||||
if (usersLastVoteInfo?.category === category) {
|
||||
// Double vote, ignore
|
||||
@@ -216,20 +216,17 @@ async function categoryVote(UUID: SegmentUUID, userID: UserID, isVIP: boolean, i
|
||||
}
|
||||
|
||||
const segmentInfo = (await db.prepare("get", `SELECT "category", "actionType", "videoID", "hashedVideoID", "service", "userID", "locked" FROM "sponsorTimes" WHERE "UUID" = ?`,
|
||||
[UUID])) as {category: Category, actionType: ActionType, videoID: VideoID, hashedVideoID: VideoIDHash, service: Service, userID: UserID, locked: number};
|
||||
[UUID], { useReplica: true })) as {category: Category, actionType: ActionType, videoID: VideoID, hashedVideoID: VideoIDHash, service: Service, userID: UserID, locked: number};
|
||||
|
||||
if (segmentInfo.actionType === ActionType.Full) {
|
||||
return { status: 400, message: "Not allowed to change category of a full video segment" };
|
||||
}
|
||||
if (segmentInfo.actionType === ActionType.Poi || category === "poi_highlight") {
|
||||
return { status: 400, message: "Not allowed to change category for single point segments" };
|
||||
if (!config.categorySupport[category]?.includes(segmentInfo.actionType) || segmentInfo.actionType === ActionType.Full) {
|
||||
return { status: 400, message: `Not allowed to change to ${category} when for segment of type ${segmentInfo.actionType}`};
|
||||
}
|
||||
if (!config.categoryList.includes(category)) {
|
||||
return { status: 400, message: "Category doesn't exist." };
|
||||
}
|
||||
|
||||
// Ignore vote if the next category is locked
|
||||
const nextCategoryLocked = await db.prepare("get", `SELECT "videoID", "category" FROM "lockCategories" WHERE "videoID" = ? AND "service" = ? AND "category" = ?`, [segmentInfo.videoID, segmentInfo.service, category]);
|
||||
const nextCategoryLocked = await db.prepare("get", `SELECT "videoID", "category" FROM "lockCategories" WHERE "videoID" = ? AND "service" = ? AND "category" = ?`, [segmentInfo.videoID, segmentInfo.service, category], { useReplica: true });
|
||||
if (nextCategoryLocked && !isVIP) {
|
||||
return { status: 200 };
|
||||
}
|
||||
@@ -239,12 +236,13 @@ async function categoryVote(UUID: SegmentUUID, userID: UserID, isVIP: boolean, i
|
||||
return { status: 200 };
|
||||
}
|
||||
|
||||
const nextCategoryInfo = await db.prepare("get", `select votes from "categoryVotes" where "UUID" = ? and category = ?`, [UUID, category]);
|
||||
const nextCategoryInfo = await db.prepare("get", `select votes from "categoryVotes" where "UUID" = ? and category = ?`, [UUID, category], { useReplica: true });
|
||||
|
||||
const timeSubmitted = Date.now();
|
||||
|
||||
const voteAmount = (isVIP || isTempVIP) ? 500 : 1;
|
||||
const ableToVote = isVIP || isTempVIP || finalResponse.finalStatus === 200 || true;
|
||||
const ableToVote = finalResponse.finalStatus === 200
|
||||
&& (await db.prepare("get", `SELECT "userID" FROM "shadowBannedUsers" WHERE "userID" = ?`, [userID], { useReplica: true })) === undefined;
|
||||
|
||||
if (ableToVote) {
|
||||
// Add the vote
|
||||
@@ -267,15 +265,15 @@ async function categoryVote(UUID: SegmentUUID, userID: UserID, isVIP: boolean, i
|
||||
}
|
||||
|
||||
// See if the submissions category is ready to change
|
||||
const currentCategoryInfo = await db.prepare("get", `select votes from "categoryVotes" where "UUID" = ? and category = ?`, [UUID, segmentInfo.category]);
|
||||
const currentCategoryInfo = await db.prepare("get", `select votes from "categoryVotes" where "UUID" = ? and category = ?`, [UUID, segmentInfo.category], { useReplica: true });
|
||||
|
||||
const submissionInfo = await db.prepare("get", `SELECT "userID", "timeSubmitted", "votes" FROM "sponsorTimes" WHERE "UUID" = ?`, [UUID]);
|
||||
const submissionInfo = await db.prepare("get", `SELECT "userID", "timeSubmitted", "votes" FROM "sponsorTimes" WHERE "UUID" = ?`, [UUID], { useReplica: true });
|
||||
const isSubmissionVIP = submissionInfo && await isUserVIP(submissionInfo.userID);
|
||||
const startingVotes = isSubmissionVIP ? 10000 : 1;
|
||||
|
||||
// Change this value from 1 in the future to make it harder to change categories
|
||||
// Done this way without ORs incase the value is zero
|
||||
const currentCategoryCount = (currentCategoryInfo === undefined || currentCategoryInfo === null) ? startingVotes : currentCategoryInfo.votes;
|
||||
const currentCategoryCount = currentCategoryInfo?.votes ?? startingVotes;
|
||||
|
||||
// Add submission as vote
|
||||
if (!currentCategoryInfo && submissionInfo) {
|
||||
@@ -287,7 +285,7 @@ async function categoryVote(UUID: SegmentUUID, userID: UserID, isVIP: boolean, i
|
||||
|
||||
//TODO: In the future, raise this number from zero to make it harder to change categories
|
||||
// VIPs change it every time
|
||||
if (nextCategoryCount - currentCategoryCount >= Math.max(Math.ceil(submissionInfo?.votes / 2), 2) || isVIP || isTempVIP || isOwnSubmission) {
|
||||
if (isVIP || isTempVIP || isOwnSubmission || nextCategoryCount - currentCategoryCount >= Math.max(Math.ceil(submissionInfo?.votes / 2), 2)) {
|
||||
// Replace the category
|
||||
await db.prepare("run", `update "sponsorTimes" set "category" = ? where "UUID" = ?`, [category, UUID]);
|
||||
}
|
||||
@@ -329,6 +327,8 @@ export async function vote(ip: IPAddress, UUID: SegmentUUID, paramUserID: UserID
|
||||
return { status: 200 };
|
||||
}
|
||||
|
||||
const originalType = type;
|
||||
|
||||
//hash the userID
|
||||
const nonAnonUserID = await getHashCache(paramUserID);
|
||||
const userID = await getHashCache(paramUserID + UUID);
|
||||
@@ -362,6 +362,19 @@ export async function vote(ip: IPAddress, UUID: SegmentUUID, paramUserID: UserID
|
||||
return { status: 400 };
|
||||
}
|
||||
|
||||
const MILLISECONDS_IN_HOUR = 3600000;
|
||||
const now = Date.now();
|
||||
const warnings = (await db.prepare("all", `SELECT "reason" FROM warnings WHERE "userID" = ? AND "issueTime" > ? AND enabled = 1`,
|
||||
[nonAnonUserID, Math.floor(now - (config.hoursAfterWarningExpires * MILLISECONDS_IN_HOUR))],
|
||||
));
|
||||
|
||||
if (warnings.length >= config.maxNumberOfActiveWarnings) {
|
||||
const warningReason = warnings[0]?.reason;
|
||||
return { status: 403, message: "Vote rejected due to a warning from a moderator. This means that we noticed you were making some common mistakes that are not malicious, and we just want to clarify the rules. " +
|
||||
"Could you please send a message in Discord or Matrix so we can further help you?" +
|
||||
`${(warningReason.length > 0 ? ` Warning reason: '${warningReason}'` : "")}` };
|
||||
}
|
||||
|
||||
// no type but has category, categoryVote
|
||||
if (!type && category) {
|
||||
return categoryVote(UUID, nonAnonUserID, isVIP, isTempVIP, isOwnSubmission, category, hashedIP, finalResponse);
|
||||
@@ -372,7 +385,7 @@ export async function vote(ip: IPAddress, UUID: SegmentUUID, paramUserID: UserID
|
||||
const isSegmentLocked = segmentInfo.locked;
|
||||
const isVideoLocked = async () => !!(await db.prepare("get", `SELECT "category" FROM "lockCategories" WHERE
|
||||
"videoID" = ? AND "service" = ? AND "category" = ? AND "actionType" = ?`,
|
||||
[segmentInfo.videoID, segmentInfo.service, segmentInfo.category, segmentInfo.actionType]));
|
||||
[segmentInfo.videoID, segmentInfo.service, segmentInfo.category, segmentInfo.actionType], { useReplica: true }));
|
||||
if (isSegmentLocked || await isVideoLocked()) {
|
||||
finalResponse.blockVote = true;
|
||||
finalResponse.webhookType = VoteWebhookType.Rejected;
|
||||
@@ -391,43 +404,30 @@ export async function vote(ip: IPAddress, UUID: SegmentUUID, paramUserID: UserID
|
||||
}
|
||||
}
|
||||
|
||||
const MILLISECONDS_IN_HOUR = 3600000;
|
||||
const now = Date.now();
|
||||
const warnings = (await db.prepare("all", `SELECT "reason" FROM warnings WHERE "userID" = ? AND "issueTime" > ? AND enabled = 1`,
|
||||
[nonAnonUserID, Math.floor(now - (config.hoursAfterWarningExpires * MILLISECONDS_IN_HOUR))],
|
||||
));
|
||||
|
||||
if (warnings.length >= config.maxNumberOfActiveWarnings) {
|
||||
const warningReason = warnings[0]?.reason;
|
||||
return { status: 403, message: "Vote rejected due to a warning from a moderator. This means that we noticed you were making some common mistakes that are not malicious, and we just want to clarify the rules. " +
|
||||
"Could you please send a message in Discord or Matrix so we can further help you?" +
|
||||
`${(warningReason.length > 0 ? ` Warning reason: '${warningReason}'` : "")}` };
|
||||
}
|
||||
|
||||
const voteTypeEnum = (type == 0 || type == 1 || type == 20) ? voteTypes.normal : voteTypes.incorrect;
|
||||
|
||||
// no restrictions on checkDuration
|
||||
// check duration of all submissions on this video
|
||||
if (type <= 0) {
|
||||
checkVideoDuration(UUID);
|
||||
checkVideoDuration(UUID).catch(Logger.error);
|
||||
}
|
||||
|
||||
try {
|
||||
// check if vote has already happened
|
||||
const votesRow = await privateDB.prepare("get", `SELECT "type" FROM "votes" WHERE "userID" = ? AND "UUID" = ?`, [userID, UUID]);
|
||||
const votesRow = await privateDB.prepare("get", `SELECT "type" FROM "votes" WHERE "userID" = ? AND "UUID" = ?`, [userID, UUID], { useReplica: true });
|
||||
|
||||
// -1 for downvote, 1 for upvote. Maybe more depending on reputation in the future
|
||||
// oldIncrementAmount will be zero if row is null
|
||||
let incrementAmount = 0;
|
||||
let oldIncrementAmount = 0;
|
||||
|
||||
if (type == 1) {
|
||||
if (type == VoteType.Upvote) {
|
||||
//upvote
|
||||
incrementAmount = 1;
|
||||
} else if (type == 0) {
|
||||
} else if (type === VoteType.Downvote || type === VoteType.Malicious) {
|
||||
//downvote
|
||||
incrementAmount = -1;
|
||||
} else if (type == 20) {
|
||||
} else if (type == VoteType.Undo) {
|
||||
//undo/cancel vote
|
||||
incrementAmount = 0;
|
||||
} else {
|
||||
@@ -435,17 +435,13 @@ export async function vote(ip: IPAddress, UUID: SegmentUUID, paramUserID: UserID
|
||||
return { status: 400 };
|
||||
}
|
||||
if (votesRow) {
|
||||
if (votesRow.type === 1) {
|
||||
//upvote
|
||||
if (votesRow.type === VoteType.Upvote) {
|
||||
oldIncrementAmount = 1;
|
||||
} else if (votesRow.type === 0) {
|
||||
//downvote
|
||||
} else if (votesRow.type === VoteType.Downvote) {
|
||||
oldIncrementAmount = -1;
|
||||
} else if (votesRow.type === 2) {
|
||||
//extra downvote
|
||||
} else if (votesRow.type === VoteType.ExtraDownvote) {
|
||||
oldIncrementAmount = -4;
|
||||
} else if (votesRow.type === 20) {
|
||||
//undo/cancel vote
|
||||
} else if (votesRow.type === VoteType.Undo) {
|
||||
oldIncrementAmount = 0;
|
||||
} else if (votesRow.type < 0) {
|
||||
//vip downvote
|
||||
@@ -466,13 +462,19 @@ export async function vote(ip: IPAddress, UUID: SegmentUUID, paramUserID: UserID
|
||||
type = incrementAmount;
|
||||
}
|
||||
|
||||
if (type === VoteType.Malicious) {
|
||||
incrementAmount = -Math.min(segmentInfo.votes + 2 - oldIncrementAmount, 5);
|
||||
type = incrementAmount;
|
||||
}
|
||||
|
||||
// Only change the database if they have made a submission before and haven't voted recently
|
||||
const userAbleToVote = (!(isOwnSubmission && incrementAmount > 0 && oldIncrementAmount >= 0)
|
||||
&& !(originalType === VoteType.Malicious && segmentInfo.actionType !== ActionType.Chapter)
|
||||
&& !finalResponse.blockVote
|
||||
&& finalResponse.finalStatus === 200
|
||||
&& (await db.prepare("get", `SELECT "userID" FROM "sponsorTimes" WHERE "userID" = ?`, [nonAnonUserID])) !== undefined
|
||||
&& (await db.prepare("get", `SELECT "userID" FROM "shadowBannedUsers" WHERE "userID" = ?`, [nonAnonUserID])) === undefined
|
||||
&& (await privateDB.prepare("get", `SELECT "UUID" FROM "votes" WHERE "UUID" = ? AND "hashedIP" = ? AND "userID" != ?`, [UUID, hashedIP, userID])) === undefined);
|
||||
&& (await db.prepare("get", `SELECT "userID" FROM "sponsorTimes" WHERE "userID" = ?`, [nonAnonUserID], { useReplica: true })) !== undefined
|
||||
&& (await db.prepare("get", `SELECT "userID" FROM "shadowBannedUsers" WHERE "userID" = ?`, [nonAnonUserID], { useReplica: true })) === undefined
|
||||
&& (await privateDB.prepare("get", `SELECT "UUID" FROM "votes" WHERE "UUID" = ? AND "hashedIP" = ? AND "userID" != ?`, [UUID, hashedIP, userID], { useReplica: true })) === undefined);
|
||||
|
||||
|
||||
const ableToVote = isVIP || isTempVIP || userAbleToVote;
|
||||
@@ -480,9 +482,9 @@ export async function vote(ip: IPAddress, UUID: SegmentUUID, paramUserID: UserID
|
||||
if (ableToVote) {
|
||||
//update the votes table
|
||||
if (votesRow) {
|
||||
await privateDB.prepare("run", `UPDATE "votes" SET "type" = ? WHERE "userID" = ? AND "UUID" = ?`, [type, userID, UUID]);
|
||||
await privateDB.prepare("run", `UPDATE "votes" SET "type" = ?, "originalType" = ? WHERE "userID" = ? AND "UUID" = ?`, [type, originalType, userID, UUID]);
|
||||
} else {
|
||||
await privateDB.prepare("run", `INSERT INTO "votes" VALUES(?, ?, ?, ?, ?)`, [UUID, userID, hashedIP, type, nonAnonUserID]);
|
||||
await privateDB.prepare("run", `INSERT INTO "votes" ("UUID", "userID", "hashedIP", "type", "normalUserID", "originalType") VALUES(?, ?, ?, ?, ?, ?)`, [UUID, userID, hashedIP, type, nonAnonUserID, originalType]);
|
||||
}
|
||||
|
||||
// update the vote count on this sponsorTime
|
||||
@@ -510,6 +512,7 @@ export async function vote(ip: IPAddress, UUID: SegmentUUID, paramUserID: UserID
|
||||
sendWebhooks({
|
||||
UUID,
|
||||
nonAnonUserID,
|
||||
originalType,
|
||||
voteTypeEnum,
|
||||
isTempVIP,
|
||||
isVIP,
|
||||
@@ -519,7 +522,7 @@ export async function vote(ip: IPAddress, UUID: SegmentUUID, paramUserID: UserID
|
||||
incrementAmount,
|
||||
oldIncrementAmount,
|
||||
finalResponse
|
||||
});
|
||||
}).catch(Logger.error);
|
||||
}
|
||||
return { status: finalResponse.finalStatus, message: finalResponse.finalMessage ?? undefined };
|
||||
} catch (err) {
|
||||
|
||||
@@ -1,13 +1,22 @@
|
||||
import { PoolConfig } from "pg";
|
||||
import * as redis from "redis";
|
||||
import { CacheOptions } from "@ajayyy/lru-diskcache";
|
||||
|
||||
interface RedisConfig extends redis.RedisClientOptions {
|
||||
enabled: boolean;
|
||||
expiryTime: number;
|
||||
getTimeout: number;
|
||||
}
|
||||
|
||||
interface CustomPostgresConfig extends PoolConfig {
|
||||
export interface CustomPostgresConfig extends PoolConfig {
|
||||
enabled: boolean;
|
||||
maxTries: number;
|
||||
maxConcurrentRequests: number;
|
||||
}
|
||||
|
||||
export interface CustomPostgresReadOnlyConfig extends CustomPostgresConfig {
|
||||
weight: number;
|
||||
readTimeout: number;
|
||||
fallbackOnFail: boolean;
|
||||
}
|
||||
|
||||
export interface SBSConfig {
|
||||
@@ -21,8 +30,11 @@ export interface SBSConfig {
|
||||
discordFailedReportChannelWebhookURL?: string;
|
||||
discordFirstTimeSubmissionsWebhookURL?: string;
|
||||
discordCompletelyIncorrectReportWebhookURL?: string;
|
||||
discordMaliciousReportWebhookURL?: string;
|
||||
neuralBlockURL?: string;
|
||||
discordNeuralBlockRejectWebhookURL?: string;
|
||||
minReputationToSubmitChapter: number;
|
||||
minReputationToSubmitFiller: number;
|
||||
userCounterURL?: string;
|
||||
proxySubmission?: string;
|
||||
behindProxy: string | boolean;
|
||||
@@ -43,7 +55,6 @@ export interface SBSConfig {
|
||||
rateLimit: {
|
||||
vote: RateLimitConfig;
|
||||
view: RateLimitConfig;
|
||||
rate: RateLimitConfig;
|
||||
};
|
||||
mysql?: any;
|
||||
privateMysql?: any;
|
||||
@@ -52,9 +63,19 @@ export interface SBSConfig {
|
||||
redis?: RedisConfig;
|
||||
maxRewardTimePerSegmentInSeconds?: number;
|
||||
postgres?: CustomPostgresConfig;
|
||||
postgresReadOnly?: CustomPostgresReadOnlyConfig;
|
||||
dumpDatabase?: DumpDatabase;
|
||||
diskCache: CacheOptions;
|
||||
diskCacheURL: string;
|
||||
crons: CronJobOptions;
|
||||
patreon: {
|
||||
clientId: string,
|
||||
clientSecret: string,
|
||||
minPrice: number,
|
||||
redirectUri: string
|
||||
}
|
||||
gumroad: {
|
||||
productPermalinks: string[],
|
||||
}
|
||||
}
|
||||
|
||||
export interface WebhookConfig {
|
||||
|
||||
24
src/types/innerTubeApi.model.ts
Normal file
24
src/types/innerTubeApi.model.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
export interface innerTubeVideoDetails {
|
||||
"videoId": string,
|
||||
"title": string,
|
||||
"lengthSeconds": string, // yes, don't ask.
|
||||
"channelId": string,
|
||||
"isOwnerViewing": boolean,
|
||||
"shortDescription": string,
|
||||
"isCrawlable": boolean,
|
||||
"thumbnail": {
|
||||
"thumbnails": [{
|
||||
"url": string,
|
||||
"width": number,
|
||||
"height": number
|
||||
}
|
||||
]
|
||||
},
|
||||
"allowRatings": boolean,
|
||||
"viewCount": string, // yes, don't ask
|
||||
"author": string,
|
||||
"isPrivate": boolean,
|
||||
"isUnpluggedCorpus": boolean,
|
||||
"isLiveContent": boolean,
|
||||
"publishDate": string
|
||||
}
|
||||
@@ -101,6 +101,7 @@ export interface VideoData {
|
||||
export interface SegmentCache {
|
||||
shadowHiddenSegmentIPs: SBRecord<VideoID, SBRecord<string, {hashedIP: HashedIP}[]>>,
|
||||
userHashedIP?: HashedIP
|
||||
userHashedIPPromise?: Promise<HashedIP>;
|
||||
}
|
||||
|
||||
export interface DBLock {
|
||||
@@ -120,3 +121,12 @@ export enum SortableFields {
|
||||
votes = "votes",
|
||||
views = "views",
|
||||
}
|
||||
|
||||
|
||||
export enum VoteType {
|
||||
Downvote = 0,
|
||||
Upvote = 1,
|
||||
ExtraDownvote = 2,
|
||||
Undo = 20,
|
||||
Malicious = 30
|
||||
}
|
||||
@@ -1,4 +1,9 @@
|
||||
import { HashedValue } from "./hash.model";
|
||||
|
||||
export type UserID = string & { __userIDBrand: unknown };
|
||||
export type HashedUserID = UserID & HashedValue;
|
||||
export type HashedUserID = UserID & HashedValue;
|
||||
|
||||
export enum Feature {
|
||||
ChapterSubmitter = 0,
|
||||
FillerSubmitter = 1
|
||||
}
|
||||
@@ -1,34 +1,45 @@
|
||||
import LRU from "@ajayyy/lru-diskcache";
|
||||
import axios, { AxiosError } from "axios";
|
||||
import { config } from "../config";
|
||||
import { Logger } from "./logger";
|
||||
|
||||
let DiskCache: LRU<string, string>;
|
||||
class DiskCache {
|
||||
async set(key: string, value: unknown): Promise<boolean> {
|
||||
if (!config.diskCacheURL) return false;
|
||||
|
||||
if (config.diskCache) {
|
||||
DiskCache = new LRU("./databases/cache", config.diskCache);
|
||||
DiskCache.init();
|
||||
} else {
|
||||
DiskCache = {
|
||||
/* eslint-disable @typescript-eslint/no-unused-vars */
|
||||
// constructor(rootPath, options): {};
|
||||
try {
|
||||
const result = await axios.post(`${config.diskCacheURL}/api/v1/item`, {
|
||||
key,
|
||||
value
|
||||
});
|
||||
|
||||
init(): void { return; },
|
||||
return result.status === 200;
|
||||
} catch (err) {
|
||||
const response = (err as AxiosError).response;
|
||||
if (!response || response.status !== 404) {
|
||||
Logger.error(`DiskCache: Error setting key ${key}: ${err}`);
|
||||
}
|
||||
|
||||
reset(): void { return; },
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
has(key: string): boolean { return false; },
|
||||
async get(key: string): Promise<unknown> {
|
||||
if (!config.diskCacheURL) return null;
|
||||
|
||||
get(key: string, opts?: {encoding?: string}): string { return null; },
|
||||
try {
|
||||
const result = await axios.get(`${config.diskCacheURL}/api/v1/item?key=${key}`, { timeout: 500 });
|
||||
|
||||
// Returns size
|
||||
set(key: string, dataOrSteam: string): Promise<number> { return new Promise(() => 0); },
|
||||
return result.status === 200 ? result.data : null;
|
||||
} catch (err) {
|
||||
const response = (err as AxiosError).response;
|
||||
if (!response || response.status !== 404) {
|
||||
Logger.error(`DiskCache: Error getting key ${key}: ${err}`);
|
||||
}
|
||||
|
||||
del(key: string): void { return; },
|
||||
|
||||
size(): number { return 0; },
|
||||
|
||||
prune(): void {return; },
|
||||
/* eslint-enable @typescript-eslint/no-unused-vars */
|
||||
};
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default DiskCache;
|
||||
const diskCache = new DiskCache();
|
||||
export default diskCache;
|
||||
11
src/utils/features.ts
Normal file
11
src/utils/features.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { db } from "../databases/databases";
|
||||
import { Feature, HashedUserID } from "../types/user.model";
|
||||
import { QueryCacher } from "./queryCacher";
|
||||
import { userFeatureKey } from "./redisKeys";
|
||||
|
||||
export async function hasFeature(userID: HashedUserID, feature: Feature): Promise<boolean> {
|
||||
return await QueryCacher.get(async () => {
|
||||
const result = await db.prepare("get", 'SELECT "feature" from "userFeatures" WHERE "userID" = ? AND "feature" = ?', [userID, feature], { useReplica: true });
|
||||
return !!result;
|
||||
}, userFeatureKey(userID, feature));
|
||||
}
|
||||
@@ -26,11 +26,13 @@ async function getFromRedis<T extends string>(key: HashedValue): Promise<T & Has
|
||||
Logger.debug(`Got data from redis: ${reply}`);
|
||||
return reply as T & HashedValue;
|
||||
}
|
||||
} catch (e) {} // eslint-disable-line no-empty
|
||||
} catch (err) {
|
||||
Logger.error(err as string);
|
||||
}
|
||||
|
||||
// Otherwise, calculate it
|
||||
const data = getHash(key, cachedHashTimes);
|
||||
redis.set(key, data);
|
||||
redis.set(redisKey, data).catch((err) => Logger.error(err));
|
||||
|
||||
return data as T & HashedValue;
|
||||
}
|
||||
59
src/utils/getVideoDetails.ts
Normal file
59
src/utils/getVideoDetails.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
import { config } from "../config";
|
||||
import { innerTubeVideoDetails } from "../types/innerTubeApi.model";
|
||||
import { APIVideoData } from "../types/youtubeApi.model";
|
||||
import { YouTubeAPI } from "../utils/youtubeApi";
|
||||
import { getPlayerData } from "../utils/innerTubeAPI";
|
||||
|
||||
export interface videoDetails {
|
||||
videoId: string,
|
||||
duration: number,
|
||||
authorId: string,
|
||||
authorName: string,
|
||||
title: string,
|
||||
published: number,
|
||||
thumbnails: {
|
||||
url: string,
|
||||
width: number,
|
||||
height: number,
|
||||
}[]
|
||||
}
|
||||
|
||||
const convertFromInnerTube = (input: innerTubeVideoDetails): videoDetails => ({
|
||||
videoId: input.videoId,
|
||||
duration: Number(input.lengthSeconds),
|
||||
authorId: input.channelId,
|
||||
authorName: input.author,
|
||||
title: input.title,
|
||||
published: new Date(input.publishDate).getTime()/1000,
|
||||
thumbnails: input.thumbnail.thumbnails
|
||||
});
|
||||
|
||||
const convertFromNewLeaf = (input: APIVideoData): videoDetails => ({
|
||||
videoId: input.videoId,
|
||||
duration: input.lengthSeconds,
|
||||
authorId: input.authorId,
|
||||
authorName: input.author,
|
||||
title: input.title,
|
||||
published: input.published,
|
||||
thumbnails: input.videoThumbnails
|
||||
});
|
||||
|
||||
async function newLeafWrapper(videoId: string, ignoreCache: boolean) {
|
||||
const result = await YouTubeAPI.listVideos(videoId, ignoreCache);
|
||||
return result?.data ?? Promise.reject();
|
||||
}
|
||||
|
||||
export function getVideoDetails(videoId: string, ignoreCache = false): Promise<videoDetails> {
|
||||
if (!config.newLeafURLs) {
|
||||
return getPlayerData(videoId, ignoreCache)
|
||||
.then(data => convertFromInnerTube(data));
|
||||
}
|
||||
return Promise.any([
|
||||
newLeafWrapper(videoId, ignoreCache)
|
||||
.then(videoData => convertFromNewLeaf(videoData)),
|
||||
getPlayerData(videoId, ignoreCache)
|
||||
.then(data => convertFromInnerTube(data))
|
||||
]).catch(() => {
|
||||
return null;
|
||||
});
|
||||
}
|
||||
58
src/utils/innerTubeAPI.ts
Normal file
58
src/utils/innerTubeAPI.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
import axios from "axios";
|
||||
import { Logger } from "./logger";
|
||||
import { innerTubeVideoDetails } from "../types/innerTubeApi.model";
|
||||
import DiskCache from "./diskCache";
|
||||
|
||||
async function getFromITube (videoID: string): Promise<innerTubeVideoDetails> {
|
||||
// start subrequest
|
||||
const url = "https://www.youtube.com/youtubei/v1/player";
|
||||
const data = {
|
||||
context: {
|
||||
client: {
|
||||
clientName: "WEB",
|
||||
clientVersion: "2.20211129.09.00"
|
||||
}
|
||||
},
|
||||
videoId: videoID
|
||||
};
|
||||
const result = await axios.post(url, data, {
|
||||
timeout: 3500
|
||||
});
|
||||
if (result.status === 200) {
|
||||
return result.data.videoDetails;
|
||||
} else {
|
||||
return Promise.reject(result.status);
|
||||
}
|
||||
}
|
||||
|
||||
export async function getPlayerData (videoID: string, ignoreCache = false): Promise<innerTubeVideoDetails> {
|
||||
if (!videoID || videoID.length !== 11 || videoID.includes(".")) {
|
||||
return Promise.reject("Invalid video ID");
|
||||
}
|
||||
|
||||
const cacheKey = `yt.itube.video.${videoID}`;
|
||||
if (!ignoreCache) { // try fetching from cache
|
||||
try {
|
||||
const data = await DiskCache.get(cacheKey);
|
||||
if (data) {
|
||||
Logger.debug(`InnerTube API: cache used for video information: ${videoID}`);
|
||||
return data as innerTubeVideoDetails;
|
||||
}
|
||||
} catch (err) {
|
||||
return Promise.reject(err);
|
||||
}
|
||||
}
|
||||
try {
|
||||
const data = await getFromITube(videoID)
|
||||
.catch(err => {
|
||||
Logger.warn(`InnerTube API Error for ${videoID}: ${err}`);
|
||||
return Promise.reject(err);
|
||||
});
|
||||
DiskCache.set(cacheKey, data)
|
||||
.then(() => Logger.debug(`InnerTube API: video information cache set for: ${videoID}`))
|
||||
.catch((err: any) => Logger.warn(err));
|
||||
return data;
|
||||
} catch (err) {
|
||||
return Promise.reject(err);
|
||||
}
|
||||
}
|
||||
@@ -1,19 +1,13 @@
|
||||
import redis from "../utils/redis";
|
||||
import { tempVIPKey } from "../utils/redisKeys";
|
||||
import { HashedUserID } from "../types/user.model";
|
||||
import { YouTubeAPI } from "../utils/youtubeApi";
|
||||
import { APIVideoInfo } from "../types/youtubeApi.model";
|
||||
import { VideoID } from "../types/segments.model";
|
||||
import { config } from "../config";
|
||||
import { Logger } from "./logger";
|
||||
|
||||
function getYouTubeVideoInfo(videoID: VideoID, ignoreCache = false): Promise<APIVideoInfo> {
|
||||
return config.newLeafURLs ? YouTubeAPI.listVideos(videoID, ignoreCache) : null;
|
||||
}
|
||||
import { getVideoDetails } from "./getVideoDetails";
|
||||
|
||||
export const isUserTempVIP = async (hashedUserID: HashedUserID, videoID: VideoID): Promise<boolean> => {
|
||||
const apiVideoInfo = await getYouTubeVideoInfo(videoID);
|
||||
const channelID = apiVideoInfo?.data?.authorId;
|
||||
const apiVideoDetails = await getVideoDetails(videoID);
|
||||
const channelID = apiVideoDetails?.authorId;
|
||||
try {
|
||||
const reply = await redis.get(tempVIPKey(hashedUserID));
|
||||
return reply && reply == channelID;
|
||||
|
||||
@@ -2,5 +2,6 @@ import { db } from "../databases/databases";
|
||||
import { HashedUserID } from "../types/user.model";
|
||||
|
||||
export async function isUserVIP(userID: HashedUserID): Promise<boolean> {
|
||||
return (await db.prepare("get", `SELECT count(*) as "userCount" FROM "vipUsers" WHERE "userID" = ? LIMIT 1`, [userID])).userCount > 0;
|
||||
return (await db.prepare("get", `SELECT count(*) as "userCount" FROM "vipUsers" WHERE "userID" = ? LIMIT 1`,
|
||||
[userID]))?.userCount > 0;
|
||||
}
|
||||
|
||||
37
src/utils/permissions.ts
Normal file
37
src/utils/permissions.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import { config } from "../config";
|
||||
import { db } from "../databases/databases";
|
||||
import { Category } from "../types/segments.model";
|
||||
import { Feature, HashedUserID } from "../types/user.model";
|
||||
import { hasFeature } from "./features";
|
||||
import { isUserVIP } from "./isUserVIP";
|
||||
import { oneOf } from "./promise";
|
||||
import { getReputation } from "./reputation";
|
||||
|
||||
interface CanSubmitResult {
|
||||
canSubmit: boolean;
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
async function lowDownvotes(userID: HashedUserID): Promise<boolean> {
|
||||
const result = await db.prepare("get", `SELECT count(*) as "submissionCount", SUM(CASE WHEN "votes" < 0 AND "views" > 5 THEN 1 ELSE 0 END) AS "downvotedSubmissions" FROM "sponsorTimes" WHERE "userID" = ?`
|
||||
, [userID], { useReplica: true });
|
||||
|
||||
return result.submissionCount > 100 && result.downvotedSubmissions / result.submissionCount < 0.15;
|
||||
}
|
||||
|
||||
export async function canSubmit(userID: HashedUserID, category: Category): Promise<CanSubmitResult> {
|
||||
switch (category) {
|
||||
case "chapter":
|
||||
return {
|
||||
canSubmit: await oneOf([isUserVIP(userID),
|
||||
lowDownvotes(userID),
|
||||
(async () => (await getReputation(userID)) > config.minReputationToSubmitChapter)(),
|
||||
hasFeature(userID, Feature.ChapterSubmitter)
|
||||
])
|
||||
};
|
||||
default:
|
||||
return {
|
||||
canSubmit: true
|
||||
};
|
||||
}
|
||||
}
|
||||
75
src/utils/promise.ts
Normal file
75
src/utils/promise.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
import { Logger } from "./logger";
|
||||
|
||||
export class PromiseTimeoutError<T> extends Error {
|
||||
promise?: Promise<T>;
|
||||
|
||||
constructor(promise?: Promise<T>) {
|
||||
super("Promise timed out");
|
||||
|
||||
this.promise = promise;
|
||||
}
|
||||
}
|
||||
|
||||
export interface PromiseWithState<T> extends Promise<T> {
|
||||
isResolved: boolean;
|
||||
isRejected: boolean;
|
||||
}
|
||||
|
||||
export function promiseOrTimeout<T>(promise: Promise<T>, timeout?: number): Promise<T> {
|
||||
return Promise.race([timeoutPomise<T>(timeout), promise]);
|
||||
}
|
||||
|
||||
export function timeoutPomise<T>(timeout?: number): Promise<T> {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (timeout) {
|
||||
setTimeout(() => {
|
||||
reject(new PromiseTimeoutError());
|
||||
}, timeout);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export function savePromiseState<T>(promise: Promise<T>): PromiseWithState<T> {
|
||||
const p = promise as PromiseWithState<T>;
|
||||
p.isResolved = false;
|
||||
p.isRejected = false;
|
||||
|
||||
p.then(() => {
|
||||
p.isResolved = true;
|
||||
}).catch(() => {
|
||||
p.isRejected = true;
|
||||
});
|
||||
|
||||
return p;
|
||||
}
|
||||
|
||||
/**
|
||||
* Allows rejection or resolve
|
||||
* Allows past resolves too, but not past rejections
|
||||
*/
|
||||
export function nextFulfilment<T>(promises: PromiseWithState<T>[]): Promise<T> {
|
||||
return Promise.race(promises.filter((p) => !p.isRejected));
|
||||
}
|
||||
|
||||
export function oneOf<T>(promises: Promise<T>[]): Promise<T> {
|
||||
return new Promise((resolve, reject) => {
|
||||
let fulfilments = 0;
|
||||
for (const promise of promises) {
|
||||
promise.then((result) => {
|
||||
fulfilments++;
|
||||
|
||||
if (result || fulfilments === promises.length) {
|
||||
resolve(result);
|
||||
}
|
||||
}).catch((err) => {
|
||||
fulfilments++;
|
||||
|
||||
if (fulfilments === promises.length) {
|
||||
reject(err);
|
||||
} else {
|
||||
Logger.error(`oneOf ignore error (promise): ${err}`);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -1,8 +1,9 @@
|
||||
import redis from "../utils/redis";
|
||||
import { Logger } from "../utils/logger";
|
||||
import { skipSegmentsHashKey, skipSegmentsKey, reputationKey, ratingHashKey, skipSegmentGroupsKey } from "./redisKeys";
|
||||
import { skipSegmentsHashKey, skipSegmentsKey, reputationKey, ratingHashKey, skipSegmentGroupsKey, userFeatureKey } from "./redisKeys";
|
||||
import { Service, VideoID, VideoIDHash } from "../types/segments.model";
|
||||
import { UserID } from "../types/user.model";
|
||||
import { Feature, HashedUserID, UserID } from "../types/user.model";
|
||||
import { config } from "../config";
|
||||
|
||||
async function get<T>(fetchFromDB: () => Promise<T>, key: string): Promise<T> {
|
||||
try {
|
||||
@@ -16,7 +17,7 @@ async function get<T>(fetchFromDB: () => Promise<T>, key: string): Promise<T> {
|
||||
|
||||
const data = await fetchFromDB();
|
||||
|
||||
redis.set(key, JSON.stringify(data));
|
||||
redis.setEx(key, config.redis?.expiryTime, JSON.stringify(data)).catch((err) => Logger.error(err));
|
||||
|
||||
return data;
|
||||
}
|
||||
@@ -52,7 +53,7 @@ async function getAndSplit<T, U extends string>(fetchFromDB: (values: U[]) => Pr
|
||||
if (valuesToBeFetched.length > 0) {
|
||||
data = await fetchFromDB(valuesToBeFetched);
|
||||
|
||||
new Promise(() => {
|
||||
void new Promise(() => {
|
||||
const newResults: Record<string, T[]> = {};
|
||||
for (const item of data) {
|
||||
const splitValue = (item as unknown as Record<string, string>)[splitKey];
|
||||
@@ -67,7 +68,7 @@ async function getAndSplit<T, U extends string>(fetchFromDB: (values: U[]) => Pr
|
||||
}
|
||||
|
||||
for (const key in newResults) {
|
||||
redis.set(key, JSON.stringify(newResults[key]));
|
||||
redis.setEx(key, config.redis?.expiryTime, JSON.stringify(newResults[key])).catch((err) => Logger.error(err));
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -77,22 +78,27 @@ async function getAndSplit<T, U extends string>(fetchFromDB: (values: U[]) => Pr
|
||||
|
||||
function clearSegmentCache(videoInfo: { videoID: VideoID; hashedVideoID: VideoIDHash; service: Service; userID?: UserID; }): void {
|
||||
if (videoInfo) {
|
||||
redis.del(skipSegmentsKey(videoInfo.videoID, videoInfo.service));
|
||||
redis.del(skipSegmentGroupsKey(videoInfo.videoID, videoInfo.service));
|
||||
redis.del(skipSegmentsHashKey(videoInfo.hashedVideoID, videoInfo.service));
|
||||
if (videoInfo.userID) redis.del(reputationKey(videoInfo.userID));
|
||||
redis.del(skipSegmentsKey(videoInfo.videoID, videoInfo.service)).catch((err) => Logger.error(err));
|
||||
redis.del(skipSegmentGroupsKey(videoInfo.videoID, videoInfo.service)).catch((err) => Logger.error(err));
|
||||
redis.del(skipSegmentsHashKey(videoInfo.hashedVideoID, videoInfo.service)).catch((err) => Logger.error(err));
|
||||
if (videoInfo.userID) redis.del(reputationKey(videoInfo.userID)).catch((err) => Logger.error(err));
|
||||
}
|
||||
}
|
||||
|
||||
function clearRatingCache(videoInfo: { hashedVideoID: VideoIDHash; service: Service;}): void {
|
||||
if (videoInfo) {
|
||||
redis.del(ratingHashKey(videoInfo.hashedVideoID, videoInfo.service));
|
||||
redis.del(ratingHashKey(videoInfo.hashedVideoID, videoInfo.service)).catch((err) => Logger.error(err));
|
||||
}
|
||||
}
|
||||
|
||||
function clearFeatureCache(userID: HashedUserID, feature: Feature): void {
|
||||
redis.del(userFeatureKey(userID, feature)).catch((err) => Logger.error(err));
|
||||
}
|
||||
|
||||
export const QueryCacher = {
|
||||
get,
|
||||
getAndSplit,
|
||||
clearSegmentCache,
|
||||
clearRatingCache
|
||||
clearRatingCache,
|
||||
clearFeatureCache
|
||||
};
|
||||
@@ -1,8 +1,8 @@
|
||||
import { config } from "../config";
|
||||
import { Logger } from "./logger";
|
||||
import { createClient } from "redis";
|
||||
import { RedisCommandArgument, RedisCommandArguments, RedisCommandRawReply } from "@node-redis/client/dist/lib/commands";
|
||||
import { ClientCommandOptions } from "@node-redis/client/dist/lib/client";
|
||||
import { RedisCommandArgument, RedisCommandArguments, RedisCommandRawReply } from "@redis/client/dist/lib/commands";
|
||||
import { RedisClientOptions } from "@redis/client/dist/lib/client";
|
||||
import { RedisReply } from "rate-limit-redis";
|
||||
|
||||
interface RedisSB {
|
||||
@@ -11,7 +11,7 @@ interface RedisSB {
|
||||
setEx(key: RedisCommandArgument, seconds: number, value: RedisCommandArgument): Promise<string>;
|
||||
del(...keys: [RedisCommandArgument]): Promise<number>;
|
||||
increment?(key: RedisCommandArgument): Promise<RedisCommandRawReply[]>;
|
||||
sendCommand(args: RedisCommandArguments, options?: ClientCommandOptions): Promise<RedisReply>;
|
||||
sendCommand(args: RedisCommandArguments, options?: RedisClientOptions): Promise<RedisReply>;
|
||||
quit(): Promise<void>;
|
||||
}
|
||||
|
||||
@@ -28,20 +28,19 @@ let exportClient: RedisSB = {
|
||||
if (config.redis?.enabled) {
|
||||
Logger.info("Connected to redis");
|
||||
const client = createClient(config.redis);
|
||||
client.connect();
|
||||
exportClient = client;
|
||||
void client.connect(); // void as we don't care about the promise
|
||||
exportClient = client as RedisSB;
|
||||
|
||||
const timeoutDuration = 200;
|
||||
const get = client.get.bind(client);
|
||||
exportClient.get = (key) => new Promise((resolve, reject) => {
|
||||
const timeout = setTimeout(() => reject(), timeoutDuration);
|
||||
const timeout = config.redis.getTimeout ? setTimeout(() => reject(), config.redis.getTimeout) : null;
|
||||
get(key).then((reply) => {
|
||||
clearTimeout(timeout);
|
||||
if (timeout !== null) clearTimeout(timeout);
|
||||
resolve(reply);
|
||||
}).catch((err) => reject(err));
|
||||
});
|
||||
exportClient.increment = (key) => new Promise((resolve, reject) =>
|
||||
client.multi()
|
||||
void client.multi()
|
||||
.incr(key)
|
||||
.expire(key, 60)
|
||||
.exec()
|
||||
@@ -49,7 +48,10 @@ if (config.redis?.enabled) {
|
||||
.catch((err) => reject(err))
|
||||
);
|
||||
client.on("error", function(error) {
|
||||
Logger.error(error);
|
||||
Logger.error(`Redis Error: ${error}`);
|
||||
});
|
||||
client.on("reconnect", () => {
|
||||
Logger.info("Redis: trying to reconnect");
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Service, VideoID, VideoIDHash } from "../types/segments.model";
|
||||
import { HashedUserID, UserID } from "../types/user.model";
|
||||
import { Feature, HashedUserID, UserID } from "../types/user.model";
|
||||
import { HashedValue } from "../types/hash.model";
|
||||
import { Logger } from "./logger";
|
||||
|
||||
@@ -46,4 +46,8 @@ export function videoLabelsHashKey(hashedVideoIDPrefix: VideoIDHash, service: Se
|
||||
if (hashedVideoIDPrefix.length !== 4) Logger.warn(`Redis skip segment hash-prefix key is not length 4! ${hashedVideoIDPrefix}`);
|
||||
|
||||
return `labels.v1.${service}.${hashedVideoIDPrefix}`;
|
||||
}
|
||||
|
||||
export function userFeatureKey (userID: HashedUserID, feature: Feature): string {
|
||||
return `user.${userID}.feature.${feature}`;
|
||||
}
|
||||
@@ -28,9 +28,9 @@ export async function getReputation(userID: UserID): Promise<number> {
|
||||
THEN 1 ELSE 0 END) AS "nonSelfDownvotedSubmissions",
|
||||
SUM(CASE WHEN "timeSubmitted" > 1596240000000 THEN "votes" ELSE 0 END) AS "votedSum",
|
||||
SUM(locked) AS "lockedSum",
|
||||
SUM(CASE WHEN "timeSubmitted" < ? AND "timeSubmitted" > 1596240000000 AND "actionType" != 'full' AND "votes" > 0 THEN 1 ELSE 0 END) AS "semiOldUpvotedSubmissions",
|
||||
SUM(CASE WHEN "timeSubmitted" < ? AND "timeSubmitted" > 1596240000000 AND "actionType" != 'full' AND "votes" > 0 THEN 1 ELSE 0 END) AS "oldUpvotedSubmissions",
|
||||
SUM(CASE WHEN "votes" > 0 AND "actionType" != 'full'
|
||||
SUM(CASE WHEN "timeSubmitted" < ? AND "timeSubmitted" > 1596240000000 AND "votes" > 0 THEN 1 ELSE 0 END) AS "semiOldUpvotedSubmissions",
|
||||
SUM(CASE WHEN "timeSubmitted" < ? AND "timeSubmitted" > 1596240000000 AND "votes" > 0 THEN 1 ELSE 0 END) AS "oldUpvotedSubmissions",
|
||||
SUM(CASE WHEN "votes" > 0
|
||||
AND NOT EXISTS (
|
||||
SELECT * FROM "sponsorTimes" as c
|
||||
WHERE (c."votes" > "a"."votes" OR c."locked" > "a"."locked") AND
|
||||
@@ -40,7 +40,7 @@ export async function getReputation(userID: UserID): Promise<number> {
|
||||
SELECT * FROM "lockCategories" as l
|
||||
WHERE l."videoID" = "a"."videoID" AND l."service" = "a"."service" AND l."category" = "a"."category" LIMIT 1)
|
||||
THEN 1 ELSE 0 END) AS "mostUpvotedInLockedVideoSum"
|
||||
FROM "sponsorTimes" as "a" WHERE "userID" = ?`, [userID, weekAgo, pastDate, userID]) as Promise<ReputationDBResult>;
|
||||
FROM "sponsorTimes" as "a" WHERE "userID" = ? AND "actionType" != 'full'`, [userID, weekAgo, pastDate, userID], { useReplica: true }) as Promise<ReputationDBResult>;
|
||||
|
||||
const result = await QueryCacher.get(fetchFromDB, reputationKey(userID));
|
||||
|
||||
@@ -55,19 +55,21 @@ function convertRange(value: number, currentMin: number, currentMax: number, tar
|
||||
}
|
||||
|
||||
export function calculateReputationFromMetrics(metrics: ReputationDBResult): number {
|
||||
if (!metrics) return 0;
|
||||
|
||||
// Grace period
|
||||
if (metrics.totalSubmissions < 5) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const downvoteRatio = metrics.downvotedSubmissions / metrics.totalSubmissions;
|
||||
if (downvoteRatio > 0.3) {
|
||||
return convertRange(Math.min(downvoteRatio, 0.7), 0.3, 0.7, -0.5, -2.5);
|
||||
if (downvoteRatio > 0.5) {
|
||||
return convertRange(Math.min(downvoteRatio, 0.7), 0.5, 0.7, -0.5, -2.5);
|
||||
}
|
||||
|
||||
const nonSelfDownvoteRatio = metrics.nonSelfDownvotedSubmissions / metrics.totalSubmissions;
|
||||
if (nonSelfDownvoteRatio > 0.05) {
|
||||
return convertRange(Math.min(nonSelfDownvoteRatio, 0.4), 0.05, 0.4, -0.5, -2.5);
|
||||
if (nonSelfDownvoteRatio > 0.3) {
|
||||
return convertRange(Math.min(nonSelfDownvoteRatio, 0.4), 0.3, 0.4, -0.5, -2.5);
|
||||
}
|
||||
|
||||
if (metrics.votedSum < 5) {
|
||||
|
||||
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;
|
||||
}
|
||||
@@ -17,7 +17,7 @@ export class YouTubeAPI {
|
||||
|
||||
if (data) {
|
||||
Logger.debug(`YouTube API: cache used for video information: ${videoID}`);
|
||||
return { err: null, data: JSON.parse(data) };
|
||||
return { err: null, data: data as APIVideoData };
|
||||
}
|
||||
} catch (err) {
|
||||
return { err: err as string | boolean, data: null };
|
||||
@@ -38,9 +38,9 @@ export class YouTubeAPI {
|
||||
return { err: data.error, data: null };
|
||||
}
|
||||
const apiResult = data as APIVideoData;
|
||||
DiskCache.set(cacheKey, JSON.stringify(apiResult))
|
||||
.catch((err: any) => Logger.warn(err))
|
||||
.then(() => Logger.debug(`YouTube API: video information cache set for: ${videoID}`));
|
||||
DiskCache.set(cacheKey, apiResult)
|
||||
.then(() => Logger.debug(`YouTube API: video information cache set for: ${videoID}`))
|
||||
.catch((err: any) => Logger.warn(err));
|
||||
|
||||
return { err: false, data: apiResult };
|
||||
} else {
|
||||
@@ -52,6 +52,5 @@ export class YouTubeAPI {
|
||||
}
|
||||
}
|
||||
|
||||
export function getMaxResThumbnail(apiInfo: APIVideoData): string | void {
|
||||
return apiInfo?.videoThumbnails?.find((elem) => elem.quality === "maxres")?.second__originalUrl;
|
||||
}
|
||||
export const getMaxResThumbnail = (videoID: string): string =>
|
||||
`https://i.ytimg.com/vi/${videoID}/maxresdefault.jpg`;
|
||||
Reference in New Issue
Block a user