Files
SponsorBlockServer/src/routes/voteOnSponsorTime.ts
Michael C 8562dc2240 lint fix
2022-09-25 02:05:51 -04:00

536 lines
26 KiB
TypeScript

import { Request, Response } from "express";
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";
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, VoteType } from "../types/segments.model";
import { QueryCacher } from "../utils/queryCacher";
import axios from "axios";
const voteTypes = {
normal: 0,
incorrect: 1,
};
enum VoteWebhookType {
Normal,
Rejected
}
interface FinalResponse {
blockVote: boolean,
finalStatus: number
finalMessage: string,
webhookType: VoteWebhookType,
webhookMessage: string
}
interface VoteData {
UUID: string;
nonAnonUserID: string;
originalType: VoteType;
voteTypeEnum: number;
isTempVIP: boolean;
isVIP: boolean;
isOwnSubmission: boolean;
row: {
votes: number;
views: number;
locked: boolean;
};
category: string;
incrementAmount: number;
oldIncrementAmount: number;
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;
if (service == Service.YouTube) {
// don't use cache since we have no information about the video length
apiVideoInfo = await getYouTubeVideoInfo(videoID);
}
const apiVideoDuration = apiVideoInfo?.data?.lengthSeconds 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]);
}
}
async function checkVideoDuration(UUID: SegmentUUID) {
const { videoID, service } = await db.prepare("get", `select "videoID", "service" from "sponsorTimes" where "UUID" = ?`, [UUID]);
let apiVideoInfo: APIVideoInfo = null;
if (service == Service.YouTube) {
// don't use cache since we have no information about the video length
apiVideoInfo = await getYouTubeVideoInfo(videoID, true);
}
const apiVideoDuration = apiVideoInfo?.data?.lengthSeconds as VideoDuration;
// if no videoDuration return early
if (isNaN(apiVideoDuration)) return;
// fetch latest submission
const latestSubmission = await db.prepare("get", `SELECT "videoDuration", "UUID", "timeSubmitted"
FROM "sponsorTimes"
WHERE "videoID" = ? AND "service" = ? AND
"hidden" = 0 AND "shadowHidden" = 0 AND
"actionType" != 'full' AND
"votes" > -2 AND "videoDuration" != 0
ORDER BY "timeSubmitted" DESC LIMIT 1`,
[videoID, service]) as {videoDuration: VideoDuration, UUID: SegmentUUID, timeSubmitted: number};
if (latestSubmission && videoDurationChanged(latestSubmission.videoDuration, apiVideoDuration)) {
Logger.info(`Video duration changed for ${videoID} from ${latestSubmission.videoDuration} to ${apiVideoDuration}`);
await db.prepare("run", `UPDATE "sponsorTimes" SET "hidden" = 1
WHERE "videoID" = ? AND "service" = ? AND "timeSubmitted" <= ?
AND "hidden" = 0 AND "shadowHidden" = 0 AND
"actionType" != 'full' AND "votes" > -2`,
[videoID, service, latestSubmission.timeSubmitted]);
}
}
async function sendWebhooks(voteData: VoteData) {
const submissionInfoRow = await db.prepare("get", `SELECT "s"."videoID", "s"."userID", s."startTime", s."endTime", s."category", u."userName",
(select count(1) from "sponsorTimes" where "userID" = s."userID") count,
(select count(1) from "sponsorTimes" where "userID" = s."userID" and votes <= -2) disregarded
FROM "sponsorTimes" s left join "userNames" u on s."userID" = u."userID" where s."UUID"=?`,
[voteData.UUID]);
const userSubmissionCountRow = await db.prepare("get", `SELECT count(*) as "submissionCount" FROM "sponsorTimes" WHERE "userID" = ?`, [voteData.nonAnonUserID]);
if (submissionInfoRow !== undefined && userSubmissionCountRow != undefined) {
let webhookURL: string = null;
if (voteData.originalType === VoteType.Malicious) {
webhookURL = config.discordMaliciousReportWebhookURL;
} else if (voteData.voteTypeEnum === voteTypes.normal) {
switch (voteData.finalResponse.webhookType) {
case VoteWebhookType.Normal:
webhookURL = config.discordReportChannelWebhookURL;
break;
case VoteWebhookType.Rejected:
webhookURL = config.discordFailedReportChannelWebhookURL;
break;
}
} else if (voteData.voteTypeEnum === voteTypes.incorrect) {
webhookURL = config.discordCompletelyIncorrectReportWebhookURL;
}
if (config.newLeafURLs !== null) {
const { err, data } = await YouTubeAPI.listVideos(submissionInfoRow.videoID);
if (err) return;
const isUpvote = voteData.incrementAmount > 0;
// Send custom webhooks
dispatchEvent(isUpvote ? "vote.up" : "vote.down", {
"user": {
"status": getVoteAuthorRaw(userSubmissionCountRow.submissionCount, voteData.isTempVIP, voteData.isVIP, voteData.isOwnSubmission),
},
"video": {
"id": submissionInfoRow.videoID,
"title": data?.title,
"url": `https://www.youtube.com/watch?v=${submissionInfoRow.videoID}`,
"thumbnail": getMaxResThumbnail(data) || null,
},
"submission": {
"UUID": voteData.UUID,
"views": voteData.row.views,
"category": voteData.category,
"startTime": submissionInfoRow.startTime,
"endTime": submissionInfoRow.endTime,
"user": {
"UUID": submissionInfoRow.userID,
"username": submissionInfoRow.userName,
"submissions": {
"total": submissionInfoRow.count,
"ignored": submissionInfoRow.disregarded,
},
},
},
"votes": {
"before": voteData.row.votes,
"after": (voteData.row.votes + voteData.incrementAmount - voteData.oldIncrementAmount),
},
});
// Send discord message
if (webhookURL !== null && !isUpvote) {
axios.post(webhookURL, {
"embeds": [{
"title": data?.title,
"url": `https://www.youtube.com/watch?v=${submissionInfoRow.videoID}&t=${(submissionInfoRow.startTime.toFixed(0) - 2)}s#requiredSegment=${voteData.UUID}`,
"description": `**${voteData.row.votes} Votes Prior | \
${(voteData.row.votes + voteData.incrementAmount - voteData.oldIncrementAmount)} Votes Now | ${voteData.row.views} \
Views**\n\n**Locked**: ${voteData.row.locked}\n\n**Submission ID:** ${voteData.UUID}\
\n**Category:** ${submissionInfoRow.category}\
\n\n**Submitted by:** ${submissionInfoRow.userName}\n${submissionInfoRow.userID}\
\n\n**Total User Submissions:** ${submissionInfoRow.count}\
\n**Ignored User Submissions:** ${submissionInfoRow.disregarded}\
\n\n**Timestamp:** \
${getFormattedTime(submissionInfoRow.startTime)} to ${getFormattedTime(submissionInfoRow.endTime)}`,
"color": 10813440,
"author": {
"name": voteData.finalResponse?.webhookMessage ??
voteData.finalResponse?.finalMessage ??
`${getVoteAuthor(userSubmissionCountRow.submissionCount, voteData.isTempVIP, voteData.isVIP, voteData.isOwnSubmission)}${voteData.row.locked ? " (Locked)" : ""}`,
},
"thumbnail": {
"url": getMaxResThumbnail(data) || "",
},
}],
})
.then(res => {
if (res.status >= 400) {
Logger.error("Error sending reported submission Discord hook");
Logger.error(JSON.stringify((res.data)));
Logger.error("\n");
}
})
.catch(err => {
Logger.error("Failed to send reported submission Discord hook.");
Logger.error(JSON.stringify(err));
Logger.error("\n");
});
}
}
}
}
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], { useReplica: true });
if (usersLastVoteInfo?.category === category) {
// Double vote, ignore
return { status: finalResponse.finalStatus };
}
const segmentInfo = (await db.prepare("get", `SELECT "category", "actionType", "videoID", "hashedVideoID", "service", "userID", "locked" FROM "sponsorTimes" WHERE "UUID" = ?`,
[UUID], { useReplica: true })) as {category: Category, actionType: ActionType, videoID: VideoID, hashedVideoID: VideoIDHash, service: Service, userID: UserID, locked: number};
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], { useReplica: true });
if (nextCategoryLocked && !isVIP) {
return { status: 200 };
}
// Ignore vote if the segment is locked
if (!isVIP && segmentInfo.locked === 1) {
return { status: 200 };
}
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 = finalResponse.finalStatus === 200
&& (await db.prepare("get", `SELECT "userID" FROM "shadowBannedUsers" WHERE "userID" = ?`, [userID], { useReplica: true })) === undefined;
if (ableToVote) {
// Add the vote
if ((await db.prepare("get", `select count(*) as count from "categoryVotes" where "UUID" = ? and category = ?`, [UUID, category])).count > 0) {
// Update the already existing db entry
await db.prepare("run", `update "categoryVotes" set "votes" = "votes" + ? where "UUID" = ? and "category" = ?`, [voteAmount, UUID, category]);
} else {
// Add a db entry
await db.prepare("run", `insert into "categoryVotes" ("UUID", "category", "votes") values (?, ?, ?)`, [UUID, category, voteAmount]);
}
// Add the info into the private db
if (usersLastVoteInfo?.votes > 0) {
// Reverse the previous vote
await db.prepare("run", `update "categoryVotes" set "votes" = "votes" - ? where "UUID" = ? and "category" = ?`, [voteAmount, UUID, usersLastVoteInfo.category]);
await privateDB.prepare("run", `update "categoryVotes" set "category" = ?, "timeSubmitted" = ?, "hashedIP" = ? where "userID" = ? and "UUID" = ?`, [category, timeSubmitted, hashedIP, userID, UUID]);
} else {
await privateDB.prepare("run", `insert into "categoryVotes" ("UUID", "userID", "hashedIP", "category", "timeSubmitted") values (?, ?, ?, ?, ?)`, [UUID, userID, hashedIP, category, timeSubmitted]);
}
// 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], { useReplica: true });
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?.votes ?? startingVotes;
// Add submission as vote
if (!currentCategoryInfo && submissionInfo) {
await db.prepare("run", `insert into "categoryVotes" ("UUID", "category", "votes") values (?, ?, ?)`, [UUID, segmentInfo.category, currentCategoryCount]);
await privateDB.prepare("run", `insert into "categoryVotes" ("UUID", "userID", "hashedIP", "category", "timeSubmitted") values (?, ?, ?, ?, ?)`, [UUID, submissionInfo.userID, "unknown", segmentInfo.category, submissionInfo.timeSubmitted]);
}
const nextCategoryCount = (nextCategoryInfo?.votes || 0) + voteAmount;
//TODO: In the future, raise this number from zero to make it harder to change categories
// VIPs change it every time
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]);
}
}
QueryCacher.clearSegmentCache(segmentInfo);
return { status: finalResponse.finalStatus };
}
export function getUserID(req: Request): UserID {
return req.query.userID as UserID;
}
export async function voteOnSponsorTime(req: Request, res: Response): Promise<Response> {
const UUID = req.query.UUID as SegmentUUID;
const paramUserID = getUserID(req);
const type = req.query.type !== undefined ? parseInt(req.query.type as string) : undefined;
const category = req.query.category as Category;
const ip = getIP(req);
const result = await vote(ip, UUID, paramUserID, type, category);
const response = res.status(result.status);
if (result.message) {
return response.send(result.message);
} else if (result.json) {
return response.json(result.json);
} else {
return response.send();
}
}
export async function vote(ip: IPAddress, UUID: SegmentUUID, paramUserID: UserID, type: number, category?: Category): Promise<{ status: number, message?: string, json?: unknown }> {
// missing key parameters
if (!UUID || !paramUserID || !(type !== undefined || category)) {
return { status: 400 };
}
// Ignore this vote, invalid
if (paramUserID.length < 30 && config.mode !== "test") {
return { status: 200 };
}
const originalType = type;
//hash the userID
const nonAnonUserID = await getHashCache(paramUserID);
const userID = await getHashCache(paramUserID + UUID);
// To force a non 200, change this early
const finalResponse: FinalResponse = {
blockVote: false,
finalStatus: 200,
finalMessage: null,
webhookType: VoteWebhookType.Normal,
webhookMessage: null
};
//hash the ip 5000 times so no one can get it from the database
const hashedIP: HashedIP = await getHashCache((ip + config.globalSalt) as IPAddress);
const segmentInfo: DBSegment = await db.prepare("get", `SELECT * from "sponsorTimes" WHERE "UUID" = ?`, [UUID]);
// segment doesnt exist
if (!segmentInfo) {
return { status: 404 };
}
const isTempVIP = await isUserTempVIP(nonAnonUserID, segmentInfo.videoID);
const isVIP = await isUserVIP(nonAnonUserID);
//check if user voting on own submission
const isOwnSubmission = nonAnonUserID === segmentInfo.userID;
// disallow vote types 10/11
if (type === 10 || type === 11) {
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);
}
// If not upvote, or an upvote on a dead segment (for ActionType.Full)
if (!isVIP && (type != 1 || segmentInfo.votes <= -2)) {
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], { useReplica: true }));
if (isSegmentLocked || await isVideoLocked()) {
finalResponse.blockVote = true;
finalResponse.webhookType = VoteWebhookType.Rejected;
finalResponse.webhookMessage = "Vote rejected: A moderator has decided that this segment is correct";
}
}
// if on downvoted non-full segment and is not VIP/ tempVIP/ submitter
if (!isNaN(type) && segmentInfo.votes <= -2 && segmentInfo.actionType !== ActionType.Full &&
!(isVIP || isTempVIP || isOwnSubmission)) {
if (type == 1) {
return { status: 403, message: "Not allowed to upvote segment with too many downvotes unless you are VIP." };
} else if (type == 0) {
// Already downvoted enough, ignore
return { status: 200 };
}
}
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).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], { 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 == VoteType.Upvote) {
//upvote
incrementAmount = 1;
} else if (type === VoteType.Downvote || type === VoteType.Malicious) {
//downvote
incrementAmount = -1;
} else if (type == VoteType.Undo) {
//undo/cancel vote
incrementAmount = 0;
} else {
//unrecongnised type of vote
return { status: 400 };
}
if (votesRow) {
if (votesRow.type === VoteType.Upvote) {
oldIncrementAmount = 1;
} else if (votesRow.type === VoteType.Downvote) {
oldIncrementAmount = -1;
} else if (votesRow.type === VoteType.ExtraDownvote) {
oldIncrementAmount = -4;
} else if (votesRow.type === VoteType.Undo) {
oldIncrementAmount = 0;
} else if (votesRow.type < 0) {
//vip downvote
oldIncrementAmount = votesRow.type;
} else if (votesRow.type === 12) {
// VIP downvote for completely incorrect
oldIncrementAmount = -500;
} else if (votesRow.type === 13) {
// VIP upvote for completely incorrect
oldIncrementAmount = 500;
}
}
// check if the increment amount should be multiplied (downvotes have more power if there have been many views)
// user is temp/ VIP/ own submission and downvoting
if ((isVIP || isTempVIP || isOwnSubmission) && incrementAmount < 0) {
incrementAmount = -(segmentInfo.votes + 2 - oldIncrementAmount);
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], { 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;
if (ableToVote) {
//update the votes table
if (votesRow) {
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" ("UUID", "userID", "hashedIP", "type", "normalUserID", "originalType") VALUES(?, ?, ?, ?, ?, ?)`, [UUID, userID, hashedIP, type, nonAnonUserID, originalType]);
}
// update the vote count on this sponsorTime
await db.prepare("run", `UPDATE "sponsorTimes" SET "votes" = "votes" + ? WHERE "UUID" = ?`, [incrementAmount - oldIncrementAmount, UUID]);
// tempVIP can bring back hidden segments
if (isTempVIP && incrementAmount > 0 && voteTypeEnum === voteTypes.normal) {
await db.prepare("run", `UPDATE "sponsorTimes" SET "hidden" = 0 WHERE "UUID" = ?`, [UUID]);
}
// additional processing for VIP
// on VIP upvote
if (isVIP && incrementAmount > 0 && voteTypeEnum === voteTypes.normal) {
// Update video duration in case that caused it to be hidden
await updateSegmentVideoDuration(UUID);
// unhide & unlock
await db.prepare("run", 'UPDATE "sponsorTimes" SET "locked" = 1, "hidden" = 0, "shadowHidden" = 0 WHERE "UUID" = ?', [UUID]);
// on VIP downvote/ undovote, also unlock submission
} else if (isVIP && incrementAmount <= 0 && voteTypeEnum === voteTypes.normal) {
await db.prepare("run", 'UPDATE "sponsorTimes" SET "locked" = 0 WHERE "UUID" = ?', [UUID]);
}
QueryCacher.clearSegmentCache(segmentInfo);
}
if (incrementAmount - oldIncrementAmount !== 0) {
sendWebhooks({
UUID,
nonAnonUserID,
originalType,
voteTypeEnum,
isTempVIP,
isVIP,
isOwnSubmission,
row: segmentInfo,
category,
incrementAmount,
oldIncrementAmount,
finalResponse
}).catch(Logger.error);
}
return { status: finalResponse.finalStatus, message: finalResponse.finalMessage ?? undefined };
} catch (err) {
Logger.error(err as string);
return { status: 500, message: finalResponse.finalMessage ?? undefined, json: { error: "Internal error creating segment vote" } };
}
}