mirror of
https://github.com/ajayyy/SponsorBlockServer.git
synced 2025-12-17 13:08:49 +03:00
537 lines
23 KiB
TypeScript
537 lines
23 KiB
TypeScript
import {config} from '../config';
|
|
import {Logger} from '../utils/logger';
|
|
import {db, privateDB} from '../databases/databases';
|
|
import {YouTubeAPI} from '../utils/youtubeApi';
|
|
import {getSubmissionUUID} from '../utils/getSubmissionUUID';
|
|
import request from 'request';
|
|
import isoDurations from 'iso8601-duration';
|
|
import fetch from 'node-fetch';
|
|
import {getHash} from '../utils/getHash';
|
|
import {getIP} from '../utils/getIP';
|
|
import {getFormattedTime} from '../utils/getFormattedTime';
|
|
import {isUserTrustworthy} from '../utils/isUserTrustworthy';
|
|
import {dispatchEvent} from '../utils/webhookUtils';
|
|
import {Request, Response} from 'express';
|
|
|
|
|
|
function sendWebhookNotification(userID: string, videoID: string, UUID: string, submissionCount: number, youtubeData: any, {submissionStart, submissionEnd}: { submissionStart: number; submissionEnd: number; }, segmentInfo: any) {
|
|
const row = db.prepare('get', "SELECT userName FROM userNames WHERE userID = ?", [userID]);
|
|
const userName = row !== undefined ? row.userName : null;
|
|
const video = youtubeData.items[0];
|
|
|
|
let scopeName = "submissions.other";
|
|
if (submissionCount <= 1) {
|
|
scopeName = "submissions.new";
|
|
}
|
|
|
|
dispatchEvent(scopeName, {
|
|
"video": {
|
|
"id": videoID,
|
|
"title": video.snippet.title,
|
|
"thumbnail": video.snippet.thumbnails.maxres ? video.snippet.thumbnails.maxres : null,
|
|
"url": "https://www.youtube.com/watch?v=" + videoID,
|
|
},
|
|
"submission": {
|
|
"UUID": UUID,
|
|
"category": segmentInfo.category,
|
|
"startTime": submissionStart,
|
|
"endTime": submissionEnd,
|
|
"user": {
|
|
"UUID": userID,
|
|
"username": userName,
|
|
},
|
|
},
|
|
});
|
|
}
|
|
|
|
function sendWebhooks(userID: string, videoID: string, UUID: string, segmentInfo: any) {
|
|
if (config.youtubeAPIKey !== null) {
|
|
const userSubmissionCountRow = db.prepare('get', "SELECT count(*) as submissionCount FROM sponsorTimes WHERE userID = ?", [userID]);
|
|
|
|
YouTubeAPI.listVideos(videoID, (err: any, data: any) => {
|
|
if (err || data.items.length === 0) {
|
|
err && Logger.error(err);
|
|
return;
|
|
}
|
|
|
|
const startTime = parseFloat(segmentInfo.segment[0]);
|
|
const endTime = parseFloat(segmentInfo.segment[1]);
|
|
sendWebhookNotification(userID, videoID, UUID, userSubmissionCountRow.submissionCount, data, {
|
|
submissionStart: startTime,
|
|
submissionEnd: endTime,
|
|
}, segmentInfo);
|
|
|
|
// If it is a first time submission
|
|
// Then send a notification to discord
|
|
if (config.discordFirstTimeSubmissionsWebhookURL === null || userSubmissionCountRow.submissionCount > 1) return;
|
|
request.post(config.discordFirstTimeSubmissionsWebhookURL, {
|
|
json: {
|
|
"embeds": [{
|
|
"title": data.items[0].snippet.title,
|
|
"url": "https://www.youtube.com/watch?v=" + videoID + "&t=" + (parseInt(startTime.toFixed(0)) - 2),
|
|
"description": "Submission ID: " + UUID +
|
|
"\n\nTimestamp: " +
|
|
getFormattedTime(startTime) + " to " + getFormattedTime(endTime) +
|
|
"\n\nCategory: " + segmentInfo.category,
|
|
"color": 10813440,
|
|
"author": {
|
|
"name": userID,
|
|
},
|
|
"thumbnail": {
|
|
"url": data.items[0].snippet.thumbnails.maxres ? data.items[0].snippet.thumbnails.maxres.url : "",
|
|
},
|
|
}],
|
|
},
|
|
}, (err, res) => {
|
|
if (err) {
|
|
Logger.error("Failed to send first time submission Discord hook.");
|
|
Logger.error(JSON.stringify(err));
|
|
Logger.error("\n");
|
|
} else if (res && res.statusCode >= 400) {
|
|
Logger.error("Error sending first time submission Discord hook");
|
|
Logger.error(JSON.stringify(res));
|
|
Logger.error("\n");
|
|
}
|
|
});
|
|
});
|
|
}
|
|
}
|
|
|
|
function sendWebhooksNB(userID: string, videoID: string, UUID: string, startTime: number, endTime: number, category: string, probability: number, ytData: any) {
|
|
const submissionInfoRow = db.prepare('get', "SELECT " +
|
|
"(select count(1) from sponsorTimes where userID = ?) count, " +
|
|
"(select count(1) from sponsorTimes where userID = ? and votes <= -2) disregarded, " +
|
|
"coalesce((select userName FROM userNames WHERE userID = ?), ?) userName",
|
|
[userID, userID, userID, userID]);
|
|
|
|
let submittedBy: string;
|
|
// If a userName was created then show both
|
|
if (submissionInfoRow.userName !== userID) {
|
|
submittedBy = submissionInfoRow.userName + "\n " + userID;
|
|
} else {
|
|
submittedBy = userID;
|
|
}
|
|
|
|
// Send discord message
|
|
if (config.discordNeuralBlockRejectWebhookURL === null) return;
|
|
request.post(config.discordNeuralBlockRejectWebhookURL, {
|
|
json: {
|
|
"embeds": [{
|
|
"title": ytData.items[0].snippet.title,
|
|
"url": "https://www.youtube.com/watch?v=" + videoID + "&t=" + (parseFloat(startTime.toFixed(0)) - 2),
|
|
"description": "**Submission ID:** " + UUID +
|
|
"\n**Timestamp:** " + getFormattedTime(startTime) + " to " + getFormattedTime(endTime) +
|
|
"\n**Predicted Probability:** " + probability +
|
|
"\n**Category:** " + category +
|
|
"\n**Submitted by:** " + submittedBy +
|
|
"\n**Total User Submissions:** " + submissionInfoRow.count +
|
|
"\n**Ignored User Submissions:** " + submissionInfoRow.disregarded,
|
|
"color": 10813440,
|
|
"thumbnail": {
|
|
"url": ytData.items[0].snippet.thumbnails.maxres ? ytData.items[0].snippet.thumbnails.maxres.url : "",
|
|
},
|
|
}],
|
|
},
|
|
}, (err, res) => {
|
|
if (err) {
|
|
Logger.error("Failed to send NeuralBlock Discord hook.");
|
|
Logger.error(JSON.stringify(err));
|
|
Logger.error("\n");
|
|
} else if (res && res.statusCode >= 400) {
|
|
Logger.error("Error sending NeuralBlock Discord hook");
|
|
Logger.error(JSON.stringify(res));
|
|
Logger.error("\n");
|
|
}
|
|
});
|
|
}
|
|
|
|
// callback: function(reject: "String containing reason the submission was rejected")
|
|
// returns: string when an error, false otherwise
|
|
|
|
// 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(submission: { videoID: any; userID: any; segments: any }) {
|
|
// Get the video information from the youtube API
|
|
if (config.youtubeAPIKey !== null) {
|
|
const {err, data} = await new Promise((resolve) => {
|
|
YouTubeAPI.listVideos(submission.videoID, (err: any, data: any) => resolve({err, data}));
|
|
});
|
|
|
|
if (err) {
|
|
return false;
|
|
} else {
|
|
// Check to see if video exists
|
|
if (data.pageInfo.totalResults === 0) {
|
|
return "No video exists with id " + submission.videoID;
|
|
} else {
|
|
const segments = submission.segments;
|
|
let nbString = "";
|
|
for (let i = 0; i < segments.length; i++) {
|
|
const startTime = parseFloat(segments[i].segment[0]);
|
|
const endTime = parseFloat(segments[i].segment[1]);
|
|
|
|
let duration = data.items[0].contentDetails.duration;
|
|
duration = isoDurations.toSeconds(isoDurations.parse(duration));
|
|
if (duration == 0) {
|
|
// Allow submission if the duration is 0 (bug in youtube api)
|
|
return false;
|
|
} else if ((endTime - startTime) > (duration / 100) * 80) {
|
|
// Reject submission if over 80% of the video
|
|
return "One of your submitted segments is over 80% of the video.";
|
|
} else {
|
|
if (segments[i].category === "sponsor") {
|
|
//Prepare timestamps to send to NB all at once
|
|
nbString = nbString + segments[i].segment[0] + "," + segments[i].segment[1] + ";";
|
|
}
|
|
}
|
|
}
|
|
// Check NeuralBlock
|
|
const neuralBlockURL = config.neuralBlockURL;
|
|
if (!neuralBlockURL) return false;
|
|
const response = await fetch(neuralBlockURL + "/api/checkSponsorSegments?vid=" + submission.videoID +
|
|
"&segments=" + nbString.substring(0, nbString.length - 1));
|
|
if (!response.ok) return false;
|
|
|
|
const nbPredictions = await response.json();
|
|
let nbDecision = false;
|
|
let predictionIdx = 0; //Keep track because only sponsor categories were submitted
|
|
for (let i = 0; i < segments.length; i++) {
|
|
if (segments[i].category === "sponsor") {
|
|
if (nbPredictions.probabilities[predictionIdx] < 0.70) {
|
|
nbDecision = true; // At least one bad entry
|
|
const startTime = parseFloat(segments[i].segment[0]);
|
|
const endTime = parseFloat(segments[i].segment[1]);
|
|
|
|
const UUID = getSubmissionUUID(submission.videoID, segments[i].category, submission.userID, startTime, endTime);
|
|
// Send to Discord
|
|
// Note, if this is too spammy. Consider sending all the segments as one Webhook
|
|
sendWebhooksNB(submission.userID, submission.videoID, UUID, startTime, endTime, segments[i].category, nbPredictions.probabilities[predictionIdx], data);
|
|
}
|
|
predictionIdx++;
|
|
}
|
|
|
|
}
|
|
if (nbDecision) {
|
|
return "Rejected based on NeuralBlock predictions.";
|
|
} else {
|
|
return false;
|
|
}
|
|
}
|
|
}
|
|
} else {
|
|
Logger.debug("Skipped YouTube API");
|
|
|
|
// Can't moderate the submission without calling the youtube API
|
|
// so allow by default.
|
|
return false;
|
|
}
|
|
}
|
|
|
|
function proxySubmission(req: Request) {
|
|
request.post(config.proxySubmission + '/api/skipSegments?userID=' + req.query.userID + '&videoID=' + req.query.videoID, {json: req.body}, (err, result) => {
|
|
if (config.mode === 'development') {
|
|
if (!err) {
|
|
Logger.debug('Proxy Submission: ' + result.statusCode + ' (' + result.body + ')');
|
|
} else {
|
|
Logger.error("Proxy Submission: Failed to make call");
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
export async function postSkipSegments(req: Request, res: Response) {
|
|
if (config.proxySubmission) {
|
|
proxySubmission(req);
|
|
}
|
|
|
|
const videoID = req.query.videoID || req.body.videoID;
|
|
let userID = req.query.userID || req.body.userID;
|
|
|
|
|
|
let segments = req.body.segments;
|
|
if (segments === undefined) {
|
|
// Use query instead
|
|
segments = [{
|
|
segment: [req.query.startTime, req.query.endTime],
|
|
category: req.query.category,
|
|
}];
|
|
}
|
|
|
|
const invalidFields = [];
|
|
if (typeof videoID !== 'string') {
|
|
invalidFields.push('videoID');
|
|
}
|
|
if (typeof userID !== 'string') {
|
|
invalidFields.push('userID');
|
|
}
|
|
if (!Array.isArray(segments) || segments.length < 1) {
|
|
invalidFields.push('segments');
|
|
}
|
|
|
|
if (invalidFields.length !== 0) {
|
|
// invalid request
|
|
const fields = invalidFields.reduce((p, c, i) => p + (i !== 0 ? ', ' : '') + c, '');
|
|
res.status(400).send(`No valid ${fields} field(s) provided`);
|
|
return;
|
|
}
|
|
|
|
//hash the userID
|
|
userID = getHash(userID);
|
|
|
|
//hash the ip 5000 times so no one can get it from the database
|
|
const hashedIP = getHash(getIP(req) + config.globalSalt);
|
|
|
|
const MILLISECONDS_IN_HOUR = 3600000;
|
|
const now = Date.now();
|
|
const warningsCount = db.prepare('get', "SELECT count(1) as count FROM warnings WHERE userID = ? AND issueTime > ?",
|
|
[userID, Math.floor(now - (config.hoursAfterWarningExpires * MILLISECONDS_IN_HOUR))],
|
|
).count;
|
|
|
|
if (warningsCount >= config.maxNumberOfActiveWarnings) {
|
|
return res.status(403).send('Submission rejected due to too many active warnings. This means that we noticed you were making some mistakes that are not malicious, and we just want to clarify the rules. Could you please send a message in Discord or Matrix along with your public userID or username?');
|
|
}
|
|
|
|
const noSegmentList = db.prepare('all', 'SELECT category from noSegments where videoID = ?', [videoID]).map((list: any) => {
|
|
return list.category;
|
|
});
|
|
|
|
//check if this user is on the vip list
|
|
const isVIP = db.prepare("get", "SELECT count(*) as userCount FROM vipUsers WHERE userID = ?", [userID]).userCount > 0;
|
|
|
|
const decreaseVotes = 0;
|
|
|
|
// Check if all submissions are correct
|
|
for (let i = 0; i < segments.length; i++) {
|
|
if (segments[i] === undefined || segments[i].segment === undefined || segments[i].category === undefined) {
|
|
//invalid request
|
|
res.status(400).send("One of your segments are invalid");
|
|
return;
|
|
}
|
|
|
|
if (!config.categoryList.includes(segments[i].category)) {
|
|
res.status(400).send("Category doesn't exist.");
|
|
return;
|
|
}
|
|
|
|
// Reject segemnt if it's in the no segments list
|
|
if (!isVIP && noSegmentList.indexOf(segments[i].category) !== -1) {
|
|
// TODO: Do something about the fradulent submission
|
|
Logger.warn("Caught a no-segment submission. userID: '" + userID + "', videoID: '" + videoID + "', category: '" + segments[i].category + "'");
|
|
res.status(403).send(
|
|
"Request rejected by auto moderator: New submissions are not allowed for the following category: '"
|
|
+ segments[i].category + "'. A moderator has decided that no new segments are needed and that all current segments of this category are timed perfectly.\n\n "
|
|
+ (segments[i].category === "sponsor" ? "Maybe the segment you are submitting is a different category that you have not enabled and is not a sponsor. " +
|
|
"Categories that aren't sponsor, such as self-promotion can be enabled in the options.\n\n " : "")
|
|
+ "If you believe this is incorrect, please contact someone on Discord.",
|
|
);
|
|
return;
|
|
}
|
|
|
|
|
|
let startTime = parseFloat(segments[i].segment[0]);
|
|
let endTime = parseFloat(segments[i].segment[1]);
|
|
|
|
if (isNaN(startTime) || isNaN(endTime)
|
|
|| startTime === Infinity || endTime === Infinity || startTime < 0 || startTime >= endTime) {
|
|
//invalid request
|
|
res.status(400).send("One of your segments times are invalid (too short, startTime before endTime, etc.)");
|
|
return;
|
|
}
|
|
|
|
if (!isVIP && segments[i].category === "sponsor" && Math.abs(startTime - endTime) < 1) {
|
|
// Too short
|
|
res.status(400).send("Sponsors must be longer than 1 second long");
|
|
return;
|
|
}
|
|
|
|
//check if this info has already been submitted before
|
|
const duplicateCheck2Row = db.prepare('get', "SELECT COUNT(*) as count FROM sponsorTimes WHERE startTime = ? " +
|
|
"and endTime = ? and category = ? and videoID = ?", [startTime, endTime, segments[i].category, videoID]);
|
|
if (duplicateCheck2Row.count > 0) {
|
|
res.sendStatus(409);
|
|
return;
|
|
}
|
|
}
|
|
|
|
// Auto moderator check
|
|
if (!isVIP) {
|
|
const autoModerateResult = await autoModerateSubmission({userID, videoID, segments});//startTime, endTime, category: segments[i].category});
|
|
if (autoModerateResult == "Rejected based on NeuralBlock predictions.") {
|
|
// If NB automod rejects, the submission will start with -2 votes.
|
|
// Note, if one submission is bad all submissions will be affected.
|
|
// However, this behavior is consistent with other automod functions
|
|
// already in place.
|
|
//decreaseVotes = -2; //Disable for now
|
|
} else if (autoModerateResult) {
|
|
//Normal automod behavior
|
|
res.status(403).send("Request rejected by auto moderator: " + autoModerateResult + " If this is an issue, send a message on Discord.");
|
|
return;
|
|
}
|
|
}
|
|
// Will be filled when submitting
|
|
const UUIDs = [];
|
|
|
|
try {
|
|
//get current time
|
|
const timeSubmitted = Date.now();
|
|
|
|
const yesterday = timeSubmitted - 86400000;
|
|
|
|
// Disable IP ratelimiting for now
|
|
if (false) {
|
|
//check to see if this ip has submitted too many sponsors today
|
|
const rateLimitCheckRow = privateDB.prepare('get', "SELECT COUNT(*) as count FROM sponsorTimes WHERE hashedIP = ? AND videoID = ? AND timeSubmitted > ?", [hashedIP, videoID, yesterday]);
|
|
|
|
if (rateLimitCheckRow.count >= 10) {
|
|
//too many sponsors for the same video from the same ip address
|
|
res.sendStatus(429);
|
|
|
|
return;
|
|
}
|
|
}
|
|
|
|
// Disable max submissions for now
|
|
if (false) {
|
|
//check to see if the user has already submitted sponsors for this video
|
|
const duplicateCheckRow = db.prepare('get', "SELECT COUNT(*) as count FROM sponsorTimes WHERE userID = ? and videoID = ?", [userID, videoID]);
|
|
|
|
if (duplicateCheckRow.count >= 16) {
|
|
//too many sponsors for the same video from the same user
|
|
res.sendStatus(429);
|
|
|
|
return;
|
|
}
|
|
}
|
|
|
|
//check to see if this user is shadowbanned
|
|
const shadowBanRow = privateDB.prepare('get', "SELECT count(*) as userCount FROM shadowBannedUsers WHERE userID = ?", [userID]);
|
|
|
|
let shadowBanned = shadowBanRow.userCount;
|
|
|
|
if (!(await isUserTrustworthy(userID))) {
|
|
//hide this submission as this user is untrustworthy
|
|
shadowBanned = 1;
|
|
}
|
|
|
|
let startingVotes = 0 + decreaseVotes;
|
|
if (isVIP) {
|
|
//this user is a vip, start them at a higher approval rating
|
|
startingVotes = 10000;
|
|
}
|
|
|
|
if (config.youtubeAPIKey !== null) {
|
|
let {err, data} = await new Promise((resolve) => {
|
|
YouTubeAPI.listVideos(videoID, (err: any, data: any) => resolve({err, data}));
|
|
});
|
|
|
|
if (err) {
|
|
Logger.error("Error while submitting when connecting to YouTube API: " + err);
|
|
} else {
|
|
//get all segments for this video and user
|
|
const allSubmittedByUser = db.prepare('all', "SELECT startTime, endTime FROM sponsorTimes WHERE userID = ? and videoID = ? and votes > -1", [userID, videoID]);
|
|
const allSegmentTimes = [];
|
|
if (allSubmittedByUser !== undefined) {
|
|
//add segments the user has previously submitted
|
|
for (const segmentInfo of allSubmittedByUser) {
|
|
allSegmentTimes.push([parseFloat(segmentInfo.startTime), parseFloat(segmentInfo.endTime)]);
|
|
}
|
|
}
|
|
|
|
//add segments they are trying to add in this submission
|
|
for (let i = 0; i < segments.length; i++) {
|
|
let startTime = parseFloat(segments[i].segment[0]);
|
|
let endTime = parseFloat(segments[i].segment[1]);
|
|
allSegmentTimes.push([startTime, endTime]);
|
|
}
|
|
|
|
//merge all the times into non-overlapping arrays
|
|
const allSegmentsSorted = mergeTimeSegments(allSegmentTimes.sort(function (a, b) {
|
|
return a[0] - b[0] || a[1] - b[1];
|
|
}));
|
|
|
|
let videoDuration = data.items[0].contentDetails.duration;
|
|
videoDuration = isoDurations.toSeconds(isoDurations.parse(videoDuration));
|
|
if (videoDuration != 0) {
|
|
let allSegmentDuration = 0;
|
|
//sum all segment times together
|
|
allSegmentsSorted.forEach(segmentInfo => allSegmentDuration += segmentInfo[1] - segmentInfo[0]);
|
|
if (allSegmentDuration > (videoDuration / 100) * 80) {
|
|
// Reject submission if all segments combine are over 80% of the video
|
|
res.status(400).send("Total length of your submitted segments are over 80% of the video.");
|
|
return;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
for (const segmentInfo of segments) {
|
|
//this can just be a hash of the data
|
|
//it's better than generating an actual UUID like what was used before
|
|
//also better for duplication checking
|
|
const UUID = getSubmissionUUID(videoID, segmentInfo.category, userID, segmentInfo.segment[0], segmentInfo.segment[1]);
|
|
|
|
try {
|
|
db.prepare('run', "INSERT INTO sponsorTimes " +
|
|
"(videoID, startTime, endTime, votes, UUID, userID, timeSubmitted, views, category, shadowHidden, hashedVideoID)" +
|
|
"VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", [
|
|
videoID, segmentInfo.segment[0], segmentInfo.segment[1], startingVotes, UUID, userID, timeSubmitted, 0, segmentInfo.category, shadowBanned, getHash(videoID, 1),
|
|
],
|
|
);
|
|
|
|
//add to private db as well
|
|
privateDB.prepare('run', "INSERT INTO sponsorTimes VALUES(?, ?, ?)", [videoID, hashedIP, timeSubmitted]);
|
|
} catch (err) {
|
|
//a DB change probably occurred
|
|
res.sendStatus(502);
|
|
Logger.error("Error when putting sponsorTime in the DB: " + videoID + ", " + segmentInfo.segment[0] + ", " +
|
|
segmentInfo.segment[1] + ", " + userID + ", " + segmentInfo.category + ". " + err);
|
|
|
|
return;
|
|
}
|
|
|
|
UUIDs.push(UUID);
|
|
}
|
|
} catch (err) {
|
|
Logger.error(err);
|
|
|
|
res.sendStatus(500);
|
|
|
|
return;
|
|
}
|
|
|
|
res.sendStatus(200);
|
|
|
|
for (let i = 0; i < segments.length; i++) {
|
|
sendWebhooks(userID, videoID, UUIDs[i], segments[i]);
|
|
}
|
|
}
|
|
|
|
// Takes an array of arrays:
|
|
// ex)
|
|
// [
|
|
// [3, 40],
|
|
// [50, 70],
|
|
// [60, 80],
|
|
// [100, 150]
|
|
// ]
|
|
// => transforms to combining overlapping segments
|
|
// [
|
|
// [3, 40],
|
|
// [50, 80],
|
|
// [100, 150]
|
|
// ]
|
|
function mergeTimeSegments(ranges: number[][]) {
|
|
const result: number[][] = [];
|
|
let last: number[];
|
|
|
|
ranges.forEach(function (r) {
|
|
if (!last || r[0] > last[1])
|
|
result.push(last = r);
|
|
else if (r[1] > last[1])
|
|
last[1] = r[1];
|
|
});
|
|
|
|
return result;
|
|
}
|