diff --git a/config.json.example b/config.json.example index 22971d9..befb39e 100644 --- a/config.json.example +++ b/config.json.example @@ -8,6 +8,7 @@ "discordReportChannelWebhookURL": null, //URL from discord if you would like notifications when someone makes a report [optional] "discordFirstTimeSubmissionsWebhookURL": null, //URL from discord if you would like notifications when someone makes a first time submission [optional] "discordCompletelyIncorrectReportWebhookURL": null, //URL from discord if you would like notifications when someone reports a submission as completely incorrect [optional] + "userCounterURL": null, // For user counting. URL to instance of https://github.com/ajayyy/PrivacyUserCount "proxySubmission": null, // Base url to proxy submissions to persist // e.g. https://sponsor.ajay.app (no trailing slash) "behindProxy": "X-Forwarded-For", //Options: "X-Forwarded-For", "Cloudflare", "X-Real-IP", anything else will mean it is not behind a proxy. True defaults to "X-Forwarded-For" "db": "./databases/sponsorTimes.db", @@ -17,5 +18,6 @@ "dbSchema": "./databases/_sponsorTimes.db.sql", "privateDBSchema": "./databases/_private.db.sql", "mode": "development", - "readOnly": false + "readOnly": false, + "webhooks": [] } diff --git a/entrypoint.sh b/entrypoint.sh index 5e14a9e..6215525 100755 --- a/entrypoint.sh +++ b/entrypoint.sh @@ -5,33 +5,20 @@ cd /usr/src/app cp /etc/sponsorblock/config.json . || cat < config.json { "port": 8080, - "mysql": { - "host": "127.0.0.1", - "port": 3306, - "database": "sponsorblock", - "user": "sponsorblock", - "password": "sponsorblock" - }, - "privateMysql": { - "host": "127.0.0.1", - "port": 3306, - "database": "sponsorblock_private", - "user": "sponsorblock", - "password": "sponsorblock" - }, - "globalSalt": "", - "adminUserID": "", - "youtubeAPIKey": "", + "globalSalt": "[CHANGE THIS]", + "adminUserID": "[CHANGE THIS]", + "youtubeAPIKey": null, "discordReportChannelWebhookURL": null, "discordFirstTimeSubmissionsWebhookURL": null, "discordAutoModWebhookURL": null, - "behindProxy": true, - "db": null, - "privateDB": null, + "proxySubmission": null, + "behindProxy": "X-Forwarded-For", + "db": "./databases/sponsorTimes.db", + "privateDB": "./databases/private.db", "createDatabaseIfNotExist": true, - "schemaFolder": null, - "dbSchema": null, - "privateDBSchema": null, + "schemaFolder": "./databases", + "dbSchema": "./databases/_sponsorTimes.db.sql", + "privateDBSchema": "./databases/_private.db.sql", "mode": "development", "readOnly": false } diff --git a/src/app.js b/src/app.js index 8617566..8cb937f 100644 --- a/src/app.js +++ b/src/app.js @@ -6,6 +6,7 @@ var config = require('./config.js'); // Middleware var corsMiddleware = require('./middleware/cors.js'); var loggerMiddleware = require('./middleware/logger.js'); +const userCounter = require('./middleware/userCounter.js'); // Routes var getSkipSegments = require('./routes/getSkipSegments.js').endpoint; @@ -33,6 +34,8 @@ app.use(corsMiddleware); app.use(loggerMiddleware); app.use(express.json()) +if (config.userCounterURL) app.use(userCounter); + // Setup pretty JSON if (config.mode === "development") app.set('json spaces', 2); diff --git a/src/databases/Mysql.js b/src/databases/Mysql.js index 40e3d76..a6b2850 100644 --- a/src/databases/Mysql.js +++ b/src/databases/Mysql.js @@ -1,6 +1,5 @@ var MysqlInterface = require('sync-mysql'); -var config = require('../config.js'); -var logger = require('../utils/logger.js'); +const logger = require('../utils/logger.js'); class Mysql { constructor(msConfig) { diff --git a/src/middleware/logger.js b/src/middleware/logger.js index 32e888b..43a0c36 100644 --- a/src/middleware/logger.js +++ b/src/middleware/logger.js @@ -1,6 +1,6 @@ const log = require('../utils/logger.js'); // log not logger to not interfere with function name module.exports = function logger (req, res, next) { - log.info('Request recieved: ' + req.url); + log.info("Request recieved: " + req.method + " " + req.url); next(); } \ No newline at end of file diff --git a/src/middleware/userCounter.js b/src/middleware/userCounter.js new file mode 100644 index 0000000..183e1b4 --- /dev/null +++ b/src/middleware/userCounter.js @@ -0,0 +1,11 @@ +var request = require('request'); + +var config = require('../config.js'); +var getIP = require('../utils/getIP.js'); +const getHash = require('../utils/getHash.js'); + +module.exports = function userCounter(req, res, next) { + request.post(config.userCounterURL + "/api/v1/addIP?hashedIP=" + getHash(getIP(req), 1)); + + next(); +} \ No newline at end of file diff --git a/src/routes/getSkipSegments.js b/src/routes/getSkipSegments.js index 7b68d3f..81aae1c 100644 --- a/src/routes/getSkipSegments.js +++ b/src/routes/getSkipSegments.js @@ -20,11 +20,10 @@ function getWeightedRandomChoice(choices, amountOfChoices) { //assign a weight to each choice let totalWeight = 0; choices = choices.map(choice => { - //multiplying by 10 makes around 13 votes the point where it the votes start not mattering as much (10 + 3) //The 3 makes -2 the minimum votes before being ignored completely - //https://www.desmos.com/calculator/ljftxolg9j + //https://www.desmos.com/calculator/c1duhfrmts //this can be changed if this system increases in popularity. - const weight = Math.sqrt((choice.votes + 3) * 10); + const weight = Math.exp((choice.votes + 3), 0.85); totalWeight += weight; return { ...choice, weight }; diff --git a/src/routes/getTopUsers.js b/src/routes/getTopUsers.js index 2b452b6..22b8ca9 100644 --- a/src/routes/getTopUsers.js +++ b/src/routes/getTopUsers.js @@ -42,11 +42,13 @@ module.exports = function getTopUsers (req, res) { let rows = db.prepare('all', "SELECT COUNT(*) as totalSubmissions, SUM(views) as viewCount," + "SUM((sponsorTimes.endTime - sponsorTimes.startTime) / 60 * sponsorTimes.views) as minutesSaved, " + + "SUM(votes) as userVotes, " + additionalFields + "IFNULL(userNames.userName, sponsorTimes.userID) as userName FROM sponsorTimes LEFT JOIN userNames ON sponsorTimes.userID=userNames.userID " + "LEFT JOIN privateDB.shadowBannedUsers ON sponsorTimes.userID=privateDB.shadowBannedUsers.userID " + "WHERE sponsorTimes.votes > -1 AND sponsorTimes.shadowHidden != 1 AND privateDB.shadowBannedUsers.userID IS NULL " + - "GROUP BY IFNULL(userName, sponsorTimes.userID) ORDER BY " + sortBy + " DESC LIMIT 100", []); + "GROUP BY IFNULL(userName, sponsorTimes.userID) HAVING userVotes > 20 " + + "ORDER BY " + sortBy + " DESC LIMIT 100", []); for (let i = 0; i < rows.length; i++) { userNames[i] = rows[i].userName; diff --git a/src/routes/getTotalStats.js b/src/routes/getTotalStats.js index 43be0b1..d2be079 100644 --- a/src/routes/getTotalStats.js +++ b/src/routes/getTotalStats.js @@ -1,21 +1,26 @@ -var db = require('../databases/databases.js').db; -var request = require('request'); +const db = require('../databases/databases.js').db; +const request = require('request'); +const config = require('../config.js'); // A cache of the number of chrome web store users -var chromeUsersCache = null; -var firefoxUsersCache = null; -var lastUserCountCheck = 0; +let chromeUsersCache = null; +let firefoxUsersCache = null; +// By the privacy friendly user counter +let apiUsersCache = null; + +let lastUserCountCheck = 0; module.exports = function getTotalStats (req, res) { let row = db.prepare('get', "SELECT COUNT(DISTINCT userID) as userCount, COUNT(*) as totalSubmissions, " + - "SUM(views) as viewCount, SUM((endTime - startTime) / 60 * views) as minutesSaved FROM sponsorTimes WHERE shadowHidden != 1", []); + "SUM(views) as viewCount, SUM((endTime - startTime) / 60 * views) as minutesSaved FROM sponsorTimes WHERE shadowHidden != 1 AND votes >= 0", []); if (row !== undefined) { //send this result res.send({ userCount: row.userCount, activeUsers: chromeUsersCache + firefoxUsersCache, + apiUsers: apiUsersCache, viewCount: row.viewCount, totalSubmissions: row.totalSubmissions, minutesSaved: row.minutesSaved @@ -26,28 +31,37 @@ module.exports = function getTotalStats (req, res) { if (now - lastUserCountCheck > 5000000) { lastUserCountCheck = now; - // Get total users - request.get("https://addons.mozilla.org/api/v3/addons/addon/sponsorblock/", function (err, firefoxResponse, body) { - try { - firefoxUsersCache = parseInt(JSON.parse(body).average_daily_users); - - request.get("https://chrome.google.com/webstore/detail/sponsorblock-for-youtube/mnjggcdmjocbbbhaepdhchncahnbgone", function(err, chromeResponse, body) { - if (body !== undefined) { - try { - chromeUsersCache = parseInt(body.match(/(?<=\)/)[0].replace(",", "")); - } catch (error) { - // Re-check later - lastUserCountCheck = 0; - } - } else { - lastUserCountCheck = 0; - } - }); - } catch (error) { - // Re-check later - lastUserCountCheck = 0; - } - }); + updateExtensionUsers(); } } +} + +function updateExtensionUsers() { + if (config.userCounterURL) { + request.get(config.userCounterURL + "/api/v1/userCount", (err, response, body) => { + apiUsersCache = Math.max(apiUsersCache, JSON.parse(body).userCount); + }); + } + + request.get("https://addons.mozilla.org/api/v3/addons/addon/sponsorblock/", function (err, firefoxResponse, body) { + try { + firefoxUsersCache = parseInt(JSON.parse(body).average_daily_users); + + request.get("https://chrome.google.com/webstore/detail/sponsorblock-for-youtube/mnjggcdmjocbbbhaepdhchncahnbgone", function(err, chromeResponse, body) { + if (body !== undefined) { + try { + chromeUsersCache = parseInt(body.match(/(?<=\)/)[0].replace(",", "")); + } catch (error) { + // Re-check later + lastUserCountCheck = 0; + } + } else { + lastUserCountCheck = 0; + } + }); + } catch (error) { + // Re-check later + lastUserCountCheck = 0; + } + }); } \ No newline at end of file diff --git a/src/routes/postSkipSegments.js b/src/routes/postSkipSegments.js index 40a3e25..15ab309 100644 --- a/src/routes/postSkipSegments.js +++ b/src/routes/postSkipSegments.js @@ -11,59 +11,89 @@ var isoDurations = require('iso8601-duration'); var getHash = require('../utils/getHash.js'); var getIP = require('../utils/getIP.js'); var getFormattedTime = require('../utils/getFormattedTime.js'); -var isUserTrustworthy = require('../utils/isUserTrustworthy.js') +var isUserTrustworthy = require('../utils/isUserTrustworthy.js'); +const { dispatchEvent } = require('../utils/webhookUtils.js'); -function sendDiscordNotification(userID, videoID, UUID, segmentInfo) { - //check if they are a first time user - //if so, send a notification to discord - if (config.youtubeAPIKey !== null && config.discordFirstTimeSubmissionsWebhookURL !== null) { +function sendWebhookNotification(userID, videoID, UUID, submissionCount, youtubeData, {submissionStart, submissionEnd}, segmentInfo) { + let row = db.prepare('get', "SELECT userName FROM userNames WHERE userID = ?", [userID]); + let userName = row !== undefined ? row.userName : null; + let 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, videoID, UUID, segmentInfo) { + if (config.youtubeAPIKey !== null) { let userSubmissionCountRow = db.prepare('get', "SELECT count(*) as submissionCount FROM sponsorTimes WHERE userID = ?", [userID]); - // If it is a first time submission - if (userSubmissionCountRow.submissionCount <= 1) { - YouTubeAPI.videos.list({ - part: "snippet", - id: videoID - }, function (err, data) { - if (err || data.items.length === 0) { - err && logger.error(err); - return; - } - - let startTime = parseFloat(segmentInfo.segment[0]); - let endTime = parseFloat(segmentInfo.segment[1]); - - request.post(config.discordFirstTimeSubmissionsWebhookURL, { - json: { - "embeds": [{ - "title": data.items[0].snippet.title, - "url": "https://www.youtube.com/watch?v=" + videoID + "&t=" + (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"); + YouTubeAPI.videos.list({ + part: "snippet", + id: videoID + }, function (err, data) { + if (err || data.items.length === 0) { + err && logger.error(err); + return; + } + + let startTime = parseFloat(segmentInfo.segment[0]); + let 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) return; + request.post(config.discordFirstTimeSubmissionsWebhookURL, { + json: { + "embeds": [{ + "title": data.items[0].snippet.title, + "url": "https://www.youtube.com/watch?v=" + videoID + "&t=" + (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"); + } }); - } + }); } } @@ -118,9 +148,9 @@ function proxySubmission(req) { 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.error('Proxy Submission: ' + result.statusCode + ' ('+result.body+')'); + logger.debug('Proxy Submission: ' + result.statusCode + ' ('+result.body+')'); } else { - logger.debug("Proxy Submission: Failed to make call"); + logger.error("Proxy Submission: Failed to make call"); } } }); @@ -179,7 +209,7 @@ module.exports = async function postSkipSegments(req, res) { let endTime = parseFloat(segments[i].segment[1]); if (isNaN(startTime) || isNaN(endTime) - || startTime === Infinity || endTime === Infinity || startTime > endTime) { + || startTime === Infinity || endTime === Infinity || startTime < 0 || startTime >= endTime) { //invalid request res.sendStatus(400); return; @@ -206,6 +236,9 @@ module.exports = async function postSkipSegments(req, res) { } } + // Will be filled when submitting + let UUIDs = []; + try { //check if this user is on the vip list let vipRow = db.prepare('get', "SELECT count(*) as userCount FROM vipUsers WHERE userID = ?", [userID]); @@ -281,8 +314,7 @@ module.exports = async function postSkipSegments(req, res) { return; } - // Discord notification - sendDiscordNotification(userID, videoID, UUID, segmentInfo); + UUIDs.push(UUID); } } catch (err) { logger.error(err); @@ -293,4 +325,8 @@ module.exports = async function postSkipSegments(req, res) { } res.sendStatus(200); + + for (let i = 0; i < segments.length; i++) { + sendWebhooks(userID, videoID, UUIDs[i], segments[i]); + } } diff --git a/src/routes/setUsername.js b/src/routes/setUsername.js index cc143fb..e5b2865 100644 --- a/src/routes/setUsername.js +++ b/src/routes/setUsername.js @@ -12,12 +12,18 @@ module.exports = function setUsername(req, res) { let adminUserIDInput = req.query.adminUserID; - if (userID == undefined || userName == undefined || userID === "undefined") { + if (userID == undefined || userName == undefined || userID === "undefined" || userName.length > 50) { //invalid request res.sendStatus(400); return; } + if (userName.includes("discord")) { + // Don't allow + res.sendStatus(200); + return; + } + if (adminUserIDInput != undefined) { //this is the admin controlling the other users account, don't hash the controling account's ID adminUserIDInput = getHash(adminUserIDInput); diff --git a/src/routes/shadowBanUser.js b/src/routes/shadowBanUser.js index 4029885..ba5052e 100644 --- a/src/routes/shadowBanUser.js +++ b/src/routes/shadowBanUser.js @@ -8,6 +8,7 @@ var getHash = require('../utils/getHash.js'); module.exports = async function shadowBanUser(req, res) { let userID = req.query.userID; + let hashedIP = req.query.hashedIP; let adminUserIDInput = req.query.adminUserID; let enabled = req.query.enabled; @@ -20,7 +21,7 @@ module.exports = async function shadowBanUser(req, res) { //if enabled is false and the old submissions should be made visible again let unHideOldSubmissions = req.query.unHideOldSubmissions !== "false"; - if (adminUserIDInput == undefined || userID == undefined) { + if (adminUserIDInput == undefined || (userID == undefined && hashedIP == undefined)) { //invalid request res.sendStatus(400); return; @@ -35,27 +36,57 @@ module.exports = async function shadowBanUser(req, res) { return; } - //check to see if this user is already shadowbanned - let row = privateDB.prepare('get', "SELECT count(*) as userCount FROM shadowBannedUsers WHERE userID = ?", [userID]); + if (userID) { + //check to see if this user is already shadowbanned + let row = privateDB.prepare('get', "SELECT count(*) as userCount FROM shadowBannedUsers WHERE userID = ?", [userID]); - if (enabled && row.userCount == 0) { - //add them to the shadow ban list + if (enabled && row.userCount == 0) { + //add them to the shadow ban list - //add it to the table - privateDB.prepare('run', "INSERT INTO shadowBannedUsers VALUES(?)", [userID]); + //add it to the table + privateDB.prepare('run', "INSERT INTO shadowBannedUsers VALUES(?)", [userID]); - //find all previous submissions and hide them - if (unHideOldSubmissions) { + //find all previous submissions and hide them + if (unHideOldSubmissions) { db.prepare('run', "UPDATE sponsorTimes SET shadowHidden = 1 WHERE userID = ?", [userID]); - } - } else if (!enabled && row.userCount > 0) { - //remove them from the shadow ban list - privateDB.prepare('run', "DELETE FROM shadowBannedUsers WHERE userID = ?", [userID]); + } + } else if (!enabled && row.userCount > 0) { + //remove them from the shadow ban list + privateDB.prepare('run', "DELETE FROM shadowBannedUsers WHERE userID = ?", [userID]); - //find all previous submissions and unhide them - if (unHideOldSubmissions) { - db.prepare('run', "UPDATE sponsorTimes SET shadowHidden = 0 WHERE userID = ?", [userID]); - } + //find all previous submissions and unhide them + if (unHideOldSubmissions) { + db.prepare('run', "UPDATE sponsorTimes SET shadowHidden = 0 WHERE userID = ?", [userID]); + } + } + } else if (hashedIP) { + //check to see if this user is already shadowbanned + // let row = privateDB.prepare('get', "SELECT count(*) as userCount FROM shadowBannedIPs WHERE hashedIP = ?", [hashedIP]); + + // if (enabled && row.userCount == 0) { + if (enabled) { + //add them to the shadow ban list + + //add it to the table + // privateDB.prepare('run', "INSERT INTO shadowBannedIPs VALUES(?)", [hashedIP]); + + + + //find all previous submissions and hide them + if (unHideOldSubmissions) { + db.prepare('run', "UPDATE sponsorTimes SET shadowHidden = 1 WHERE timeSubmitted IN " + + "(SELECT privateDB.timeSubmitted FROM sponsorTimes LEFT JOIN privateDB.sponsorTimes as privateDB ON sponsorTimes.timeSubmitted=privateDB.timeSubmitted " + + "WHERE privateDB.hashedIP = ?)", [hashedIP]); + } + } else if (!enabled && row.userCount > 0) { + // //remove them from the shadow ban list + // privateDB.prepare('run', "DELETE FROM shadowBannedUsers WHERE userID = ?", [userID]); + + // //find all previous submissions and unhide them + // if (unHideOldSubmissions) { + // db.prepare('run', "UPDATE sponsorTimes SET shadowHidden = 0 WHERE userID = ?", [userID]); + // } + } } res.sendStatus(200); diff --git a/src/routes/voteOnSponsorTime.js b/src/routes/voteOnSponsorTime.js index 9ce6afa..8f9b381 100644 --- a/src/routes/voteOnSponsorTime.js +++ b/src/routes/voteOnSponsorTime.js @@ -5,6 +5,7 @@ var getHash = require('../utils/getHash.js'); var getIP = require('../utils/getIP.js'); var getFormattedTime = require('../utils/getFormattedTime.js'); var isUserTrustworthy = require('../utils/isUserTrustworthy.js'); +const { getVoteAuthor, getVoteAuthorRaw, dispatchEvent } = require('../utils/webhookUtils.js'); var databases = require('../databases/databases.js'); var db = databases.db; @@ -13,6 +14,126 @@ var YouTubeAPI = require('../utils/youtubeAPI.js'); var request = require('request'); const logger = require('../utils/logger.js'); +const voteTypes = { + normal: 0, + incorrect: 1 +} + +/** + * @param {Object} voteData + * @param {string} voteData.UUID + * @param {string} voteData.nonAnonUserID + * @param {number} voteData.voteTypeEnum + * @param {boolean} voteData.isVIP + * @param {boolean} voteData.isOwnSubmission + * @param voteData.row + * @param {string} voteData.category + * @param {number} voteData.incrementAmount + * @param {number} voteData.oldIncrementAmount + */ +function sendWebhooks(voteData) { + let submissionInfoRow = 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]); + + let userSubmissionCountRow = db.prepare('get', "SELECT count(*) as submissionCount FROM sponsorTimes WHERE userID = ?", [voteData.nonAnonUserID]); + + if (submissionInfoRow !== undefined && userSubmissionCountRow != undefined) { + let webhookURL = null; + if (voteData.voteTypeEnum === voteTypes.normal) { + webhookURL = config.discordReportChannelWebhookURL; + } else if (voteData.voteTypeEnum === voteTypes.incorrect) { + webhookURL = config.discordCompletelyIncorrectReportWebhookURL; + } + + if (config.youtubeAPIKey !== null) { + YouTubeAPI.videos.list({ + part: "snippet", + id: submissionInfoRow.videoID + }, function (err, data) { + if (err || data.items.length === 0) { + err && logger.error(err); + return; + } + let isUpvote = voteData.incrementAmount > 0; + // Send custom webhooks + dispatchEvent(isUpvote ? "vote.up" : "vote.down", { + "user": { + "status": getVoteAuthorRaw(userSubmissionCountRow.submissionCount, voteData.isVIP, voteData.isOwnSubmission) + }, + "video": { + "id": submissionInfoRow.videoID, + "title": data.items[0].snippet.title, + "url": "https://www.youtube.com/watch?v=" + submissionInfoRow.videoID, + "thumbnail": data.items[0].snippet.thumbnails.maxres ? data.items[0].snippet.thumbnails.maxres.url : "" + }, + "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) { + request.post(webhookURL, { + json: { + "embeds": [{ + "title": data.items[0].snippet.title, + "url": "https://www.youtube.com/watch?v=" + submissionInfoRow.videoID + + "&t=" + (submissionInfoRow.startTime.toFixed(0) - 2), + "description": "**" + voteData.row.votes + " Votes Prior | " + + (voteData.row.votes + voteData.incrementAmount - voteData.oldIncrementAmount) + " Votes Now | " + voteData.row.views + + " Views**\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": getVoteAuthor(userSubmissionCountRow.submissionCount, voteData.isVIP, voteData.isOwnSubmission) + }, + "thumbnail": { + "url": data.items[0].snippet.thumbnails.maxres ? data.items[0].snippet.thumbnails.maxres.url : "", + } + }] + } + }, (err, res) => { + if (err) { + logger.error("Failed to send reported submission Discord hook."); + logger.error(JSON.stringify(err)); + logger.error("\n"); + } else if (res && res.statusCode >= 400) { + logger.error("Error sending reported submission Discord hook"); + logger.error(JSON.stringify(res)); + logger.error("\n"); + } + }); + } + + }); + } + } +} + function categoryVote(UUID, userID, isVIP, category, hashedIP, res) { // Check if they've already made a vote let previousVoteInfo = privateDB.prepare('get', "select count(*) as votes, category from categoryVotes where UUID = ? and userID = ?", [UUID, userID]); @@ -97,11 +218,14 @@ async function voteOnSponsorTime(req, res) { //check if this user is on the vip list let isVIP = db.prepare('get', "SELECT count(*) as userCount FROM vipUsers WHERE userID = ?", [nonAnonUserID]).userCount > 0; + //check if user voting on own submission + let isOwnSubmission = db.prepare("get", "SELECT UUID as submissionCount FROM sponsorTimes where userID = ? AND UUID = ?", [nonAnonUserID, UUID]) !== undefined; + if (type === undefined && category !== undefined) { return categoryVote(UUID, userID, isVIP, category, hashedIP, res); } - if (type == 1 && !isVIP) { + if (type == 1 && !isVIP && !isOwnSubmission) { // Check if upvoting hidden segment let voteInfo = db.prepare('get', "SELECT votes FROM sponsorTimes WHERE UUID = ?", [UUID]); @@ -111,11 +235,6 @@ async function voteOnSponsorTime(req, res) { } } - let voteTypes = { - normal: 0, - incorrect: 1 - } - let voteTypeEnum = (type == 0 || type == 1) ? voteTypes.normal : voteTypes.incorrect; try { @@ -166,87 +285,19 @@ async function voteOnSponsorTime(req, res) { let row = db.prepare('get', "SELECT votes, views FROM sponsorTimes WHERE UUID = ?", [UUID]); if (voteTypeEnum === voteTypes.normal) { - if (isVIP && incrementAmount < 0) { + if ((isVIP || isOwnSubmission) && incrementAmount < 0) { //this user is a vip and a downvote incrementAmount = - (row.votes + 2 - oldIncrementAmount); type = incrementAmount; } } else if (voteTypeEnum == voteTypes.incorrect) { - if (isVIP) { + if (isVIP || isOwnSubmission) { //this user is a vip and a downvote incrementAmount = 500 * incrementAmount; type = incrementAmount < 0 ? 12 : 13; } } - // Send discord message - if (incrementAmount < 0) { - // Get video ID - let submissionInfoRow = 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=?", - [UUID]); - - let userSubmissionCountRow = db.prepare('get', "SELECT count(*) as submissionCount FROM sponsorTimes WHERE userID = ?", [nonAnonUserID]); - - if (submissionInfoRow !== undefined && userSubmissionCountRow != undefined) { - let webhookURL = null; - if (voteTypeEnum === voteTypes.normal) { - webhookURL = config.discordReportChannelWebhookURL; - } else if (voteTypeEnum === voteTypes.incorrect) { - webhookURL = config.discordCompletelyIncorrectReportWebhookURL; - } - - if (config.youtubeAPIKey !== null && webhookURL !== null) { - YouTubeAPI.videos.list({ - part: "snippet", - id: submissionInfoRow.videoID - }, function (err, data) { - if (err || data.items.length === 0) { - err && logger.error(err); - return; - } - - request.post(webhookURL, { - json: { - "embeds": [{ - "title": data.items[0].snippet.title, - "url": "https://www.youtube.com/watch?v=" + submissionInfoRow.videoID - + "&t=" + (submissionInfoRow.startTime.toFixed(0) - 2), - "description": "**" + row.votes + " Votes Prior | " + (row.votes + incrementAmount - oldIncrementAmount) + " Votes Now | " + row.views - + " Views**\n\n**Submission ID:** " + 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": userSubmissionCountRow.submissionCount === 0 ? "Report by New User" : (isVIP ? "Report by VIP User" : "") - }, - "thumbnail": { - "url": data.items[0].snippet.thumbnails.maxres ? data.items[0].snippet.thumbnails.maxres.url : "", - } - }] - } - }, (err, res) => { - if (err) { - logger.error("Failed to send reported submission Discord hook."); - logger.error(JSON.stringify(err)); - logger.error("\n"); - } else if (res && res.statusCode >= 400) { - logger.error("Error sending reported submission Discord hook"); - logger.error(JSON.stringify(res)); - logger.error("\n"); - } - }); - }); - } - } - } - // Only change the database if they have made a submission before and haven't voted recently let ableToVote = isVIP || (db.prepare("get", "SELECT userID FROM sponsorTimes WHERE userID = ?", [nonAnonUserID]) !== undefined @@ -299,6 +350,18 @@ async function voteOnSponsorTime(req, res) { } res.sendStatus(200); + + sendWebhooks({ + UUID, + nonAnonUserID, + voteTypeEnum, + isVIP, + isOwnSubmission, + row, + category, + incrementAmount, + oldIncrementAmount + }); } catch (err) { logger.error(err); @@ -311,4 +374,4 @@ module.exports = { endpoint: function (req, res) { voteOnSponsorTime(req, res); }, - }; \ No newline at end of file + }; diff --git a/src/utils/getFormattedTime.js b/src/utils/getFormattedTime.js index 6f3ef40..0d86271 100644 --- a/src/utils/getFormattedTime.js +++ b/src/utils/getFormattedTime.js @@ -1,8 +1,9 @@ //converts time in seconds to minutes:seconds -module.exports = function getFormattedTime(seconds) { - let minutes = Math.floor(seconds / 60); - let secondsDisplay = Math.round(seconds - minutes * 60); - if (secondsDisplay < 10) { +module.exports = function getFormattedTime(totalSeconds) { + let minutes = Math.floor(totalSeconds / 60); + let seconds = totalSeconds - minutes * 60; + let secondsDisplay = seconds.toFixed(3); + if (seconds < 10) { //add a zero secondsDisplay = "0" + secondsDisplay; } diff --git a/src/utils/logger.js b/src/utils/logger.js index 351e45c..66ebb03 100644 --- a/src/utils/logger.js +++ b/src/utils/logger.js @@ -7,6 +7,34 @@ const levels = { DEBUG: "DEBUG" }; +const colors = { + Reset: "\x1b[0m", + Bright: "\x1b[1m", + Dim: "\x1b[2m", + Underscore: "\x1b[4m", + Blink: "\x1b[5m", + Reverse: "\x1b[7m", + Hidden: "\x1b[8m", + + FgBlack: "\x1b[30m", + FgRed: "\x1b[31m", + FgGreen: "\x1b[32m", + FgYellow: "\x1b[33m", + FgBlue: "\x1b[34m", + FgMagenta: "\x1b[35m", + FgCyan: "\x1b[36m", + FgWhite: "\x1b[37m", + + BgBlack: "\x1b[40m", + BgRed: "\x1b[41m", + BgGreen: "\x1b[42m", + BgYellow: "\x1b[43m", + BgBlue: "\x1b[44m", + BgMagenta: "\x1b[45m", + BgCyan: "\x1b[46m", + BgWhite: "\x1b[47m", +} + const settings = { ERROR: true, WARN: true, @@ -18,13 +46,17 @@ if (config.mode === 'development') { settings.INFO = true; settings.DEBUG = true; } else if (config.mode === 'test') { - settings.DEBUG = true; + settings.WARN = false; } function log(level, string) { if (!!settings[level]) { + let color = colors.Bright; + if (level === levels.ERROR) color = colors.FgRed; + if (level === levels.WARN) color = colors.FgYellow; + if (level.length === 4) {level = level + " "}; // ensure logs are aligned - console.log(level + " " + new Date().toISOString() + " : " + string); + console.log(colors.Dim, level + " " + new Date().toISOString() + ": ", color, string, colors.Reset); } } diff --git a/src/utils/webhookUtils.js b/src/utils/webhookUtils.js new file mode 100644 index 0000000..6da1d62 --- /dev/null +++ b/src/utils/webhookUtils.js @@ -0,0 +1,52 @@ +const config = require('../config.js'); +const logger = require('../utils/logger.js'); +const request = require('request'); + +function getVoteAuthorRaw(submissionCount, isVIP, isOwnSubmission) { + if (isOwnSubmission) { + return "self"; + } else if (isVIP) { + return "vip"; + } else if (submissionCount === 0) { + return "new"; + } else { + return "other"; + }; +}; + +function getVoteAuthor(submissionCount, isVIP, isOwnSubmission) { + if (submissionCount === 0) { + return "Report by New User"; + } else if (isVIP) { + return "Report by VIP User"; + } else if (isOwnSubmission) { + return "Report by Submitter"; + } + + return ""; +} + +function dispatchEvent(scope, data) { + let webhooks = config.webhooks; + if (webhooks === undefined || webhooks.length === 0) return; + logger.debug("Dispatching webhooks"); + webhooks.forEach(webhook => { + let webhookURL = webhook.url; + let authKey = webhook.key; + let scopes = webhook.scopes || []; + if (!scopes.includes(scope.toLowerCase())) return; + request.post(webhookURL, {json: data, headers: { + "Authorization": authKey, + "Event-Type": scope // Maybe change this in the future? + }}).on('error', (e) => { + logger.warn('Couldn\'t send webhook to ' + webhook.url); + logger.warn(e); + }); + }); +} + +module.exports = { + getVoteAuthorRaw, + getVoteAuthor, + dispatchEvent +} \ No newline at end of file diff --git a/test.json b/test.json index 28373a1..503aa5d 100644 --- a/test.json +++ b/test.json @@ -15,5 +15,36 @@ "dbSchema": "./databases/_sponsorTimes.db.sql", "privateDBSchema": "./databases/_private.db.sql", "mode": "test", - "readOnly": false + "readOnly": false, + "webhooks": [ + { + "url": "http://127.0.0.1:8081/CustomWebhook", + "key": "superSecretKey", + "scopes": [ + "vote.up", + "vote.down" + ] + }, { + "url": "http://127.0.0.1:8081/FailedWebhook", + "key": "superSecretKey", + "scopes": [ + "vote.up", + "vote.down" + ] + }, { + "url": "http://127.0.0.1:8099/WrongPort", + "key": "superSecretKey", + "scopes": [ + "vote.up", + "vote.down" + ] + }, { + "url": "http://unresolvable.host:8081/FailedWebhook", + "key": "superSecretKey", + "scopes": [ + "vote.up", + "vote.down" + ] + } + ] } diff --git a/test/cases/voteOnSponsorTime.js b/test/cases/voteOnSponsorTime.js index 1ce4b7c..bcbca78 100644 --- a/test/cases/voteOnSponsorTime.js +++ b/test/cases/voteOnSponsorTime.js @@ -20,6 +20,8 @@ describe('voteOnSponsorTime', () => { db.exec(startOfQuery + "('voter-submitter2', 1, 11, 2, 'vote-uuid-9', '" + getHash("randomID2") + "', 0, 50, 'sponsor', 0)"); db.exec(startOfQuery + "('voter-submitter2', 1, 11, 2, 'vote-uuid-10', '" + getHash("randomID3") + "', 0, 50, 'sponsor', 0)"); db.exec(startOfQuery + "('voter-submitter2', 1, 11, 2, 'vote-uuid-11', '" + getHash("randomID4") + "', 0, 50, 'sponsor', 0)"); + db.exec(startOfQuery + "('own-submission-video', 1, 11, 500, 'own-submission-uuid', '"+ getHash('own-submission-id') +"', 0, 50, 'sponsor', 0)"); + db.exec(startOfQuery + "('not-own-submission-video', 1, 11, 500, 'not-own-submission-uuid', '"+ getHash('somebody-else-id') +"', 0, 50, 'sponsor', 0)"); db.exec("INSERT INTO vipUsers (userID) VALUES ('" + getHash("VIPUser") + "')"); privateDB.exec("INSERT INTO shadowBannedUsers (userID) VALUES ('" + getHash("randomID4") + "')"); @@ -151,6 +153,42 @@ describe('voteOnSponsorTime', () => { }); }); + it('should be able to completely downvote your own segment', (done) => { + request.get(utils.getbaseURL() + + "/api/voteOnSponsorTime?userID=own-submission-id&UUID=own-submission-uuid&type=0", null, + (err, res, body) => { + if (err) done(err); + else if (res.statusCode === 200) { + let row = db.prepare('get', "SELECT votes FROM sponsorTimes WHERE UUID = ?", ["own-submission-uuid"]); + if (row.votes <= -2) { + done() + } else { + done("Vote did not succeed. Submission went from 500 votes to " + row.votes); + } + } else { + done("Status code was " + res.statusCode); + } + }); + }); + + it('should not be able to completely downvote somebody elses segment', (done) => { + request.get(utils.getbaseURL() + + "/api/voteOnSponsorTime?userID=randomID2&UUID=not-own-submission-uuid&type=0", null, + (err, res, body) => { + if (err) done(err); + else if (res.statusCode === 200) { + let row = db.prepare('get', "SELECT votes FROM sponsorTimes WHERE UUID = ?", ["not-own-submission-uuid"]); + if (row.votes === 499) { + done() + } else { + done("Vote did not succeed. Submission went from 500 votes to " + row.votes); + } + } else { + done("Status code was " + res.statusCode); + } + }); + }); + it('Should be able to vote for a category and it should immediately change (for now)', (done) => { request.get(utils.getbaseURL() + "/api/voteOnSponsorTime?userID=randomID2&UUID=vote-uuid-4&category=intro", null, diff --git a/test/mocks.js b/test/mocks.js index b58a47f..aeaed4b 100644 --- a/test/mocks.js +++ b/test/mocks.js @@ -15,6 +15,12 @@ app.post('/CompletelyIncorrectReportWebhook', (req, res) => { res.sendStatus(200); }); + +app.post('/CustomWebhook', (req, res) => { + res.sendStatus(200); +}); + + module.exports = function createMockServer(callback) { return app.listen(config.mockPort, callback); } \ No newline at end of file