mirror of
https://github.com/ajayyy/SponsorBlockServer.git
synced 2025-12-25 00:48:22 +03:00
Merge branch 'master' of https://github.com/ajayyy/SponsorBlockServer into fullVideoLabels
This commit is contained in:
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) {
|
||||
|
||||
Reference in New Issue
Block a user