diff --git a/config.json.example b/config.json.example index 634cc0c..a2eee23 100644 --- a/config.json.example +++ b/config.json.example @@ -10,6 +10,7 @@ "discordCompletelyIncorrectReportWebhookURL": null, //URL from discord if you would like notifications when someone reports a submission as completely incorrect [optional] "neuralBlockURL": null, // URL to check submissions against neural block. Ex. https://ai.neuralblock.app "discordNeuralBlockRejectWebhookURL": null, //URL from discord if you would like notifications when NeuralBlock rejects a submission [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", @@ -20,5 +21,6 @@ "privateDBSchema": "./databases/_private.db.sql", "mode": "development", "readOnly": false, - "webhooks": [] + "webhooks": [], + "categoryList": ["sponsor", "intro", "outro", "interaction", "selfpromo", "music_offtopic"] // List of supported categories any other category will be rejected } diff --git a/src/app.js b/src/app.js index 1ce1674..c70115d 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; @@ -31,6 +32,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/middleware/userCounter.js b/src/middleware/userCounter.js new file mode 100644 index 0000000..85be545 --- /dev/null +++ b/src/middleware/userCounter.js @@ -0,0 +1,16 @@ +var request = require('request'); + +const config = require('../config.js'); +const getIP = require('../utils/getIP.js'); +const getHash = require('../utils/getHash.js'); +const logger = require('../utils/logger.js'); + +module.exports = function userCounter(req, res, next) { + try { + request.post(config.userCounterURL + "/api/v1/addIP?hashedIP=" + getHash(getIP(req), 1)); + } catch(e) { + logger.debug("Failing to connect to user counter at: " + config.userCounterURL); + } + + next(); +} \ No newline at end of file diff --git a/src/routes/getTotalStats.js b/src/routes/getTotalStats.js index e4cfb67..6d3841e 100644 --- a/src/routes/getTotalStats.js +++ b/src/routes/getTotalStats.js @@ -1,53 +1,69 @@ -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, " + + 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 AND votes >= 0", []); - if (row !== undefined) { - //send this result - res.send({ - userCount: row.userCount, - activeUsers: chromeUsersCache + firefoxUsersCache, - viewCount: row.viewCount, - totalSubmissions: row.totalSubmissions, - minutesSaved: row.minutesSaved - }); + if (row !== undefined) { + let extensionUsers = chromeUsersCache + firefoxUsersCache; - // Check if the cache should be updated (every ~14 hours) - let now = Date.now(); - if (now - lastUserCountCheck > 5000000) { - lastUserCountCheck = now; + //send this result + res.send({ + userCount: row.userCount, + activeUsers: extensionUsers, + apiUsers: Math.max(apiUsersCache, extensionUsers), + viewCount: row.viewCount, + totalSubmissions: row.totalSubmissions, + minutesSaved: row.minutesSaved + }); - // 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); + // Check if the cache should be updated (every ~14 hours) + let now = Date.now(); + if (now - lastUserCountCheck > 5000000) { + lastUserCountCheck = now; - 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 b24e810..ab80d25 100644 --- a/src/routes/postSkipSegments.js +++ b/src/routes/postSkipSegments.js @@ -64,7 +64,7 @@ function sendWebhooks(userID, videoID, UUID, segmentInfo) { // If it is a first time submission // Then send a notification to discord - if (config.discordFirstTimeSubmissionsWebhookURL === null) return; + if (config.discordFirstTimeSubmissionsWebhookURL === null || userSubmissionCountRow.submissionCount > 1) return; request.post(config.discordFirstTimeSubmissionsWebhookURL, { json: { "embeds": [{ @@ -163,7 +163,7 @@ async function autoModerateSubmission(submission) { }); if (err) { - return "Couldn't get video information."; + return false; } else { // Check to see if video exists if (data.pageInfo.totalResults === 0) { @@ -267,7 +267,7 @@ module.exports = async function postSkipSegments(req, res) { //check if all correct inputs are here and the length is 1 second or more if (videoID == undefined || userID == undefined || segments == undefined || segments.length < 1) { //invalid request - res.sendStatus(400); + res.status(400).send("Parameters are not valid"); return; } @@ -286,9 +286,14 @@ module.exports = async function postSkipSegments(req, res) { for (let i = 0; i < segments.length; i++) { if (segments[i] === undefined || segments[i].segment === undefined || segments[i].category === undefined) { //invalid request - res.sendStatus(400); + 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; + } let startTime = parseFloat(segments[i].segment[0]); let endTime = parseFloat(segments[i].segment[1]); @@ -296,7 +301,7 @@ module.exports = async function postSkipSegments(req, res) { if (isNaN(startTime) || isNaN(endTime) || startTime === Infinity || endTime === Infinity || startTime < 0 || startTime >= endTime) { //invalid request - res.sendStatus(400); + res.status(400).send("One of your segments times are invalid (too short, startTime before endTime, etc.)"); return; } diff --git a/src/routes/setUsername.js b/src/routes/setUsername.js index 989e712..e5b2865 100644 --- a/src/routes/setUsername.js +++ b/src/routes/setUsername.js @@ -12,7 +12,7 @@ module.exports = function setUsername(req, res) { let adminUserIDInput = req.query.adminUserID; - if (userID == undefined || userName == undefined || userID === "undefined" || username.length > 50) { + if (userID == undefined || userName == undefined || userID === "undefined" || userName.length > 50) { //invalid request res.sendStatus(400); return; diff --git a/src/routes/shadowBanUser.js b/src/routes/shadowBanUser.js index ba5052e..3617a94 100644 --- a/src/routes/shadowBanUser.js +++ b/src/routes/shadowBanUser.js @@ -1,5 +1,3 @@ -var config = require('../config.js'); - var databases = require('../databases/databases.js'); var db = databases.db; var privateDB = databases.privateDB; @@ -30,7 +28,8 @@ module.exports = async function shadowBanUser(req, res) { //hash the userID adminUserIDInput = getHash(adminUserIDInput); - if (adminUserIDInput !== config.adminUserID) { + let isVIP = db.prepare("get", "SELECT count(*) as userCount FROM vipUsers WHERE userID = ?", [adminUserIDInput]).userCount > 0; + if (!isVIP) { //not authorized res.sendStatus(403); return; diff --git a/src/routes/voteOnSponsorTime.js b/src/routes/voteOnSponsorTime.js index f3be68b..7fe4f18 100644 --- a/src/routes/voteOnSponsorTime.js +++ b/src/routes/voteOnSponsorTime.js @@ -150,6 +150,11 @@ function categoryVote(UUID, userID, isVIP, category, hashedIP, res) { res.status("400").send("Submission doesn't exist."); return; } + + if (!config.categoryList.includes(category)) { + res.status("400").send("Category doesn't exist."); + return; + } let timeSubmitted = Date.now(); diff --git a/src/utils/webhookUtils.js b/src/utils/webhookUtils.js index 6da1d62..9b1e0e7 100644 --- a/src/utils/webhookUtils.js +++ b/src/utils/webhookUtils.js @@ -17,10 +17,10 @@ function getVoteAuthorRaw(submissionCount, isVIP, isOwnSubmission) { 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"; + } else if (isVIP) { + return "Report by VIP User"; } return ""; diff --git a/test.json b/test.json index 841322e..8905d83 100644 --- a/test.json +++ b/test.json @@ -48,5 +48,6 @@ "vote.down" ] } - ] + ], + "categoryList": ["sponsor", "intro", "outro", "interaction", "selfpromo", "music_offtopic"] } diff --git a/test/cases/voteOnSponsorTime.js b/test/cases/voteOnSponsorTime.js index bcbca78..615eefb 100644 --- a/test/cases/voteOnSponsorTime.js +++ b/test/cases/voteOnSponsorTime.js @@ -22,6 +22,8 @@ describe('voteOnSponsorTime', () => { 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(startOfQuery + "('incorrect-category', 1, 11, 500, 'incorrect-category', '"+ getHash('somebody-else-id') +"', 0, 50, 'sponsor', 0)"); + db.exec(startOfQuery + "('incorrect-category-change', 1, 11, 500, 'incorrect-category-change', '"+ 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") + "')"); @@ -207,6 +209,24 @@ describe('voteOnSponsorTime', () => { }); }); + it('Should not able to change to an invalid category', (done) => { + request.get(utils.getbaseURL() + + "/api/voteOnSponsorTime?userID=randomID2&UUID=incorrect-category&category=fakecategory", null, + (err, res, body) => { + if (err) done(err); + else if (res.statusCode === 400) { + let row = db.prepare('get', "SELECT category FROM sponsorTimes WHERE UUID = ?", ["incorrect-category"]); + if (row.category === "sponsor") { + done() + } else { + done("Vote did not succeed. Submission went from sponsor to " + row.category); + } + } else { + done("Status code was " + res.statusCode); + } + }); + }); + it('Should be able to change your 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=outro", null, @@ -225,6 +245,29 @@ describe('voteOnSponsorTime', () => { }); }); + + it('Should not be able to change your vote to an invalid category', (done) => { + const vote = (inputCat, assertCat, callback) => { + request.get(utils.getbaseURL() + + "/api/voteOnSponsorTime?userID=randomID2&UUID=incorrect-category-change&category="+inputCat, null, + (err) => { + if (err) done(err); + else{ + let row = db.prepare('get', "SELECT category FROM sponsorTimes WHERE UUID = ?", ["incorrect-category-change"]); + if (row.category === assertCat) { + callback(); + } else { + done("Vote did not succeed. Submission went from sponsor to " + row.category); + } + } + }); + }; + vote("sponsor", "sponsor", () => { + vote("fakeCategory", "sponsor", done); + }); + }); + + it('VIP should be able to vote for a category and it should immediately change', (done) => { request.get(utils.getbaseURL() + "/api/voteOnSponsorTime?userID=VIPUser&UUID=vote-uuid-5&category=outro", null,