Merge pull request #64 from Joe-Dowd/refactor

Refactor
This commit is contained in:
Ajay Ramachandran 2020-04-01 17:25:44 -04:00 committed by GitHub
commit 0f7d1dd801
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
35 changed files with 2549 additions and 1477 deletions

2
.gitignore vendored
View file

@ -91,6 +91,8 @@ typings/
databases/sponsorTimes.db
databases/private.db
databases/sponsorTimesReal.db
test/databases/sponsorTimes.db
test/databases/private.db
# Config files
config.json

1063
index.js

File diff suppressed because it is too large Load diff

1540
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -4,7 +4,7 @@
"description": "Server that holds the SponsorBlock database",
"main": "index.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1",
"test": "node test.js",
"start": "node index.js"
},
"author": "Ajay Ramachandran",
@ -15,5 +15,8 @@
"http": "0.0.0",
"uuid": "^3.3.2",
"youtube-api": "^2.0.10"
},
"devDependencies": {
"mocha": "^7.1.1"
}
}

82
src/app.js Normal file
View file

@ -0,0 +1,82 @@
var express = require('express');
// Create a service (the app object is just a callback).
var app = express();
var config = require('./config.js');
// Middleware
var corsMiddleware = require('./middleware/cors.js');
var loggerMiddleware = require('./middleware/logger.js');
// Routes
var getVideoSponsorTimes = require('./routes/getVideoSponsorTimes.js');
var submitSponsorTimes = require('./routes/submitSponsorTimes.js');
var voteOnSponsorTime = require('./routes/voteOnSponsorTime.js');
var viewedVideoSponsorTime = require('./routes/viewedVideoSponsorTime.js');
var setUsername = require('./routes/setUsername.js');
var getUsername = require('./routes/getUsername.js');
var shadowBanUser = require('./routes/shadowBanUser.js');
var addUserAsVIP = require('./routes/addUserAsVIP.js');
var getSavedTimeForUser = require('./routes/getSavedTimeForUser.js');
var getViewsForUser = require('./routes/getViewsForUser.js');
var getTopUsers = require('./routes/getTopUsers.js');
var getTotalStats = require('./routes/getTotalStats.js');
var getDaysSavedFormatted = require('./routes/getDaysSavedFormatted.js');
//setup CORS correctly
app.use(corsMiddleware);
app.use(loggerMiddleware);
//add the get function
app.get('/api/getVideoSponsorTimes', getVideoSponsorTimes);
//add the post function
app.get('/api/postVideoSponsorTimes', submitSponsorTimes);
app.post('/api/postVideoSponsorTimes', submitSponsorTimes);
//voting endpoint
app.get('/api/voteOnSponsorTime', voteOnSponsorTime);
app.post('/api/voteOnSponsorTime', voteOnSponsorTime);
//Endpoint when a sponsorTime is used up
app.get('/api/viewedVideoSponsorTime', viewedVideoSponsorTime);
app.post('/api/viewedVideoSponsorTime', viewedVideoSponsorTime);
//To set your username for the stats view
app.post('/api/setUsername', setUsername);
//get what username this user has
app.get('/api/getUsername', getUsername);
//Endpoint used to hide a certain user's data
app.post('/api/shadowBanUser', shadowBanUser);
//Endpoint used to make a user a VIP user with special privileges
app.post('/api/addUserAsVIP', addUserAsVIP);
//Gets all the views added up for one userID
//Useful to see how much one user has contributed
app.get('/api/getViewsForUser', getViewsForUser);
//Gets all the saved time added up (views * sponsor length) for one userID
//Useful to see how much one user has contributed
//In minutes
app.get('/api/getSavedTimeForUser', getSavedTimeForUser);
app.get('/api/getTopUsers', getTopUsers);
//send out totals
//send the total submissions, total views and total minutes saved
app.get('/api/getTotalStats', getTotalStats);
//send out a formatted time saved total
app.get('/api/getdayssavedformatted', getDaysSavedFormatted);
app.get('/database.db', function (req, res) {
res.sendfile("./databases/sponsortimes.db", { root: __dirname });
});
// Create an HTTP service.
module.exports = function createServer (callback) {
return app.listen(config.port, callback);
}

12
src/config.js Normal file
View file

@ -0,0 +1,12 @@
var fs = require('fs');
var config = undefined;
// Check to see if launched in test mode
if (process.env.npm_lifecycle_script === 'node test.js') {
config = JSON.parse(fs.readFileSync('test.json'));
} else {
config = JSON.parse(fs.readFileSync('config.json'));
}
module.exports = config;

View file

@ -0,0 +1,30 @@
var config = require('../config.js');
var Sqlite3 = require('better-sqlite3');
var fs = require('fs');
let options = {
readonly: config.readOnly
};
var db = new Sqlite3(config.db, options);
var privateDB = new Sqlite3(config.privateDB, options);
if (config.createDatabaseIfNotExist && !config.readOnly) {
if (fs.existsSync(config.dbSchema)) db.exec(fs.readFileSync(config.dbSchema).toString());
if (fs.existsSync(config.privateDBSchema)) privateDB.exec(fs.readFileSync(config.privateDBSchema).toString());
}
// Enable WAL mode checkpoint number
if (!config.readOnly && config.mode === "production") {
db.exec("PRAGMA journal_mode=WAL;");
db.exec("PRAGMA wal_autocheckpoint=1;");
}
// Enable Memory-Mapped IO
db.exec("pragma mmap_size= 500000000;");
privateDB.exec("pragma mmap_size= 500000000;");
module.exports = {
db: db,
privateDB: privateDB
};

5
src/middleware/cors.js Normal file
View file

@ -0,0 +1,5 @@
module.exports = function corsMiddleware(req, res, next) {
res.header("Access-Control-Allow-Origin", "*");
res.header("Access-Control-Allow-Headers", "Origin, X-Requested-With, Content-Type, Accept");
next();
}

7
src/middleware/logger.js Normal file
View file

@ -0,0 +1,7 @@
var fs = require('fs');
var config = require('../config.js');
module.exports = function logger (req, res, next) {
(config.mode === "development") && console.log('Request recieved: ' + req.url);
next();
}

View file

@ -0,0 +1,46 @@
var fs = require('fs');
var config = require('../config.js');
var db = require('../databases/databases.js').db;
var getHash = require('../utils/getHash.js');
module.exports = async function addUserAsVIP (req, res) {
let userID = req.query.userID;
let adminUserIDInput = req.query.adminUserID;
let enabled = req.query.enabled;
if (enabled === undefined){
enabled = true;
} else {
enabled = enabled === "true";
}
if (userID == undefined || adminUserIDInput == undefined) {
//invalid request
res.sendStatus(400);
return;
}
//hash the userID
adminUserIDInput = getHash(adminUserIDInput);
if (adminUserIDInput !== adminUserID) {
//not authorized
res.sendStatus(403);
return;
}
//check to see if this user is already a vip
let row = db.prepare("SELECT count(*) as userCount FROM vipUsers WHERE userID = ?").get(userID);
if (enabled && row.userCount == 0) {
//add them to the vip list
db.prepare("INSERT INTO vipUsers VALUES(?)").run(userID);
} else if (!enabled && row.userCount > 0) {
//remove them from the shadow ban list
db.prepare("DELETE FROM vipUsers WHERE userID = ?").run(userID);
}
res.sendStatus(200);
}

View file

@ -0,0 +1,12 @@
var db = require('../databases/databases.js');
module.exports = function getDaysSavedFormatted (req, res) {
let row = db.prepare("select sum((endtime - starttime) / 60 / 60 / 24 * views) as dayssaved from sponsortimes where shadowhidden != 1").get();
if (row !== undefined) {
//send this result
res.send({
dayssaved: row.dayssaved.tofixed(2)
});
}
}

View file

@ -0,0 +1,31 @@
var db = require('../databases/databases.js').db;
module.exports = function getSavedTimeForUser (req, res) {
let userID = req.query.userID;
if (userID == undefined) {
//invalid request
res.sendStatus(400);
return;
}
//hash the userID
userID = getHash(userID);
try {
let row = db.prepare("SELECT SUM((endTime - startTime) / 60 * views) as minutesSaved FROM sponsorTimes WHERE userID = ? AND votes > -1 AND shadowHidden != 1 ").get(userID);
if (row.minutesSaved != null) {
res.send({
timeSaved: row.minutesSaved
});
} else {
res.sendStatus(404);
}
} catch (err) {
console.log(err);
res.sendStatus(500);
return;
}
}

51
src/routes/getTopUsers.js Normal file
View file

@ -0,0 +1,51 @@
var db = require('../databases/databases.js').db;
module.exports = function getTopUsers (req, res) {
let sortType = req.query.sortType;
if (sortType == undefined) {
//invalid request
res.sendStatus(400);
return;
}
//setup which sort type to use
let sortBy = "";
if (sortType == 0) {
sortBy = "minutesSaved";
} else if (sortType == 1) {
sortBy = "viewCount";
} else if (sortType == 2) {
sortBy = "totalSubmissions";
} else {
//invalid request
res.sendStatus(400);
return;
}
let userNames = [];
let viewCounts = [];
let totalSubmissions = [];
let minutesSaved = [];
let rows = db.prepare("SELECT COUNT(*) as totalSubmissions, SUM(views) as viewCount," +
"SUM((sponsorTimes.endTime - sponsorTimes.startTime) / 60 * sponsorTimes.views) as minutesSaved, " +
"IFNULL(userNames.userName, sponsorTimes.userID) as userName FROM sponsorTimes LEFT JOIN userNames ON sponsorTimes.userID=userNames.userID " +
"WHERE sponsorTimes.votes > -1 AND sponsorTimes.shadowHidden != 1 GROUP BY IFNULL(userName, sponsorTimes.userID) ORDER BY " + sortBy + " DESC LIMIT 100").all();
for (let i = 0; i < rows.length; i++) {
userNames[i] = rows[i].userName;
viewCounts[i] = rows[i].viewCount;
totalSubmissions[i] = rows[i].totalSubmissions;
minutesSaved[i] = rows[i].minutesSaved;
}
//send this result
res.send({
userNames: userNames,
viewCounts: viewCounts,
totalSubmissions: totalSubmissions,
minutesSaved: minutesSaved
});
}

View file

@ -0,0 +1,53 @@
var db = require('../databases/databases.js').db;
var request = require('request');
// A cache of the number of chrome web store users
var chromeUsersCache = null;
var firefoxUsersCache = null;
var lastUserCountCheck = 0;
module.exports = function getTotalStats (req, res) {
let row = db.prepare("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").get();
if (row !== undefined) {
//send this result
res.send({
userCount: row.userCount,
activeUsers: chromeUsersCache + firefoxUsersCache,
viewCount: row.viewCount,
totalSubmissions: row.totalSubmissions,
minutesSaved: row.minutesSaved
});
// Check if the cache should be updated (every ~14 hours)
let now = Date.now();
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(/(?<=\<span class=\"e-f-ih\" title=\").*?(?= users\">)/)[0].replace(",", ""));
} catch (error) {
// Re-check later
lastUserCountCheck = 0;
}
} else {
lastUserCountCheck = 0;
}
});
} catch (error) {
// Re-check later
lastUserCountCheck = 0;
}
});
}
}
}

36
src/routes/getUsername.js Normal file
View file

@ -0,0 +1,36 @@
var db = require('../databases/databases.js').db;
var getHash = require('../utils/getHash.js');
module.exports = function getUsername (req, res) {
let userID = req.query.userID;
if (userID == undefined) {
//invalid request
res.sendStatus(400);
return;
}
//hash the userID
userID = getHash(userID);
try {
let row = db.prepare("SELECT userName FROM userNames WHERE userID = ?").get(userID);
if (row !== undefined) {
res.send({
userName: row.userName
});
} else {
//no username yet, just send back the userID
res.send({
userName: userID
});
}
} catch (err) {
console.log(err);
res.sendStatus(500);
return;
}
}

View file

@ -0,0 +1,270 @@
var fs = require('fs');
var config = require('../config.js');
var databases = require('../databases/databases.js');
var db = databases.db;
var privateDB = databases.privateDB;
var getHash = require('../utils/getHash.js');
var getIP = require('../utils/getIP.js');
//gets the getWeightedRandomChoice for each group in an array of groups
function getWeightedRandomChoiceForArray(choiceGroups, weights) {
let finalChoices = [];
//the indexes either chosen to be added to final indexes or chosen not to be added
let choicesDealtWith = [];
//for each choice group, what are the sums of the weights
let weightSums = [];
for (let i = 0; i < choiceGroups.length; i++) {
//find weight sums for this group
weightSums.push(0);
for (let j = 0; j < choiceGroups[i].length; j++) {
//only if it is a positive vote, otherwise it is probably just a sponsor time with slightly wrong time
if (weights[choiceGroups[i][j]] > 0) {
weightSums[weightSums.length - 1] += weights[choiceGroups[i][j]];
}
}
//create a random choice for this group
let randomChoice = getWeightedRandomChoice(choiceGroups[i], weights, 1)
finalChoices.push(randomChoice.finalChoices);
for (let j = 0; j < randomChoice.choicesDealtWith.length; j++) {
choicesDealtWith.push(randomChoice.choicesDealtWith[j])
}
}
return {
finalChoices: finalChoices,
choicesDealtWith: choicesDealtWith,
weightSums: weightSums
};
}
//gets a weighted random choice from the indexes array based on the weights.
//amountOfChoices speicifies the amount of choices to return, 1 or more.
//choices are unique
function getWeightedRandomChoice(choices, weights, amountOfChoices) {
if (amountOfChoices > choices.length) {
//not possible, since all choices must be unique
return null;
}
let finalChoices = [];
let choicesDealtWith = [];
let sqrtWeightsList = [];
//the total of all the weights run through the cutom sqrt function
let totalSqrtWeights = 0;
for (let j = 0; j < choices.length; j++) {
//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
//this can be changed if this system increases in popularity.
let sqrtVote = Math.sqrt((weights[choices[j]] + 3) * 10);
sqrtWeightsList.push(sqrtVote)
totalSqrtWeights += sqrtVote;
//this index has now been deat with
choicesDealtWith.push(choices[j]);
}
//iterate and find amountOfChoices choices
let randomNumber = Math.random();
//this array will keep adding to this variable each time one sqrt vote has been dealt with
//this is the sum of all the sqrtVotes under this index
let currentVoteNumber = 0;
for (let j = 0; j < sqrtWeightsList.length; j++) {
if (randomNumber > currentVoteNumber / totalSqrtWeights && randomNumber < (currentVoteNumber + sqrtWeightsList[j]) / totalSqrtWeights) {
//this one was randomly generated
finalChoices.push(choices[j]);
//remove that from original array, for next recursion pass if it happens
choices.splice(j, 1);
break;
}
//add on to the count
currentVoteNumber += sqrtWeightsList[j];
}
//add on the other choices as well using recursion
if (amountOfChoices > 1) {
let otherChoices = getWeightedRandomChoice(choices, weights, amountOfChoices - 1).finalChoices;
//add all these choices to the finalChoices array being returned
for (let i = 0; i < otherChoices.length; i++) {
finalChoices.push(otherChoices[i]);
}
}
return {
finalChoices: finalChoices,
choicesDealtWith: choicesDealtWith
};
}
//This function will find sponsor times that are contained inside of eachother, called similar sponsor times
//Only one similar time will be returned, randomly generated based on the sqrt of votes.
//This allows new less voted items to still sometimes appear to give them a chance at getting votes.
//Sponsor times with less than -1 votes are already ignored before this function is called
function getVoteOrganisedSponsorTimes(sponsorTimes, votes, UUIDs) {
//list of sponsors that are contained inside eachother
let similarSponsors = [];
for (let i = 0; i < sponsorTimes.length; i++) {
//see if the start time is located between the start and end time of the other sponsor time.
for (let j = i + 1; j < sponsorTimes.length; j++) {
if (sponsorTimes[j][0] >= sponsorTimes[i][0] && sponsorTimes[j][0] <= sponsorTimes[i][1]) {
//sponsor j is contained in sponsor i
similarSponsors.push([i, j]);
}
}
}
let similarSponsorsGroups = [];
//once they have been added to a group, they don't need to be dealt with anymore
let dealtWithSimilarSponsors = [];
//create lists of all the similar groups (if 1 and 2 are similar, and 2 and 3 are similar, the group is 1, 2, 3)
for (let i = 0; i < similarSponsors.length; i++) {
if (dealtWithSimilarSponsors.includes(i)) {
//dealt with already
continue;
}
//this is the group of indexes that are similar
let group = similarSponsors[i];
for (let j = 0; j < similarSponsors.length; j++) {
if (group.includes(similarSponsors[j][0]) || group.includes(similarSponsors[j][1])) {
//this is a similar group
group.push(similarSponsors[j][0]);
group.push(similarSponsors[j][1]);
dealtWithSimilarSponsors.push(j);
}
}
similarSponsorsGroups.push(group);
}
//remove duplicate indexes in group arrays
for (let i = 0; i < similarSponsorsGroups.length; i++) {
uniqueArray = similarSponsorsGroups[i].filter(function(item, pos, self) {
return self.indexOf(item) == pos;
});
similarSponsorsGroups[i] = uniqueArray;
}
let weightedRandomIndexes = getWeightedRandomChoiceForArray(similarSponsorsGroups, votes);
let finalSponsorTimeIndexes = weightedRandomIndexes.finalChoices;
//the sponsor times either chosen to be added to finalSponsorTimeIndexes or chosen not to be added
let finalSponsorTimeIndexesDealtWith = weightedRandomIndexes.choicesDealtWith;
let voteSums = weightedRandomIndexes.weightSums;
//convert these into the votes
for (let i = 0; i < finalSponsorTimeIndexes.length; i++) {
//it should use the sum of votes, since anyone upvoting a similar sponsor is upvoting the existence of that sponsor.
votes[finalSponsorTimeIndexes[i]] = voteSums[i];
}
//find the indexes never dealt with and add them
for (let i = 0; i < sponsorTimes.length; i++) {
if (!finalSponsorTimeIndexesDealtWith.includes(i)) {
finalSponsorTimeIndexes.push(i)
}
}
//if there are too many indexes, find the best 4
if (finalSponsorTimeIndexes.length > 8) {
finalSponsorTimeIndexes = getWeightedRandomChoice(finalSponsorTimeIndexes, votes, 8).finalChoices;
}
//convert this to a final array to return
let finalSponsorTimes = [];
for (let i = 0; i < finalSponsorTimeIndexes.length; i++) {
finalSponsorTimes.push(sponsorTimes[finalSponsorTimeIndexes[i]]);
}
//convert this to a final array of UUIDs as well
let finalUUIDs = [];
for (let i = 0; i < finalSponsorTimeIndexes.length; i++) {
finalUUIDs.push(UUIDs[finalSponsorTimeIndexes[i]]);
}
return {
sponsorTimes: finalSponsorTimes,
UUIDs: finalUUIDs
};
}
module.exports = function (req, res) {
let videoID = req.query.videoID;
let sponsorTimes = [];
let votes = []
let UUIDs = [];
let hashedIP = getHash(getIP(req) + config.globalSalt);
try {
let rows = db.prepare("SELECT startTime, endTime, votes, UUID, shadowHidden FROM sponsorTimes WHERE videoID = ? ORDER BY startTime").all(videoID);
for (let i = 0; i < rows.length; i++) {
//check if votes are above -1
if (rows[i].votes < -1) {
//too untrustworthy, just ignore it
continue;
}
//check if shadowHidden
//this means it is hidden to everyone but the original ip that submitted it
if (rows[i].shadowHidden == 1) {
//get the ip
//await the callback
let hashedIPRow = privateDB.prepare("SELECT hashedIP FROM sponsorTimes WHERE videoID = ?").all(videoID);
if (!hashedIPRow.some((e) => e.hashedIP === hashedIP)) {
//this isn't their ip, don't send it to them
continue;
}
}
sponsorTimes.push([]);
let index = sponsorTimes.length - 1;
sponsorTimes[index][0] = rows[i].startTime;
sponsorTimes[index][1] = rows[i].endTime;
votes[index] = rows[i].votes;
UUIDs[index] = rows[i].UUID;
}
if (sponsorTimes.length == 0) {
res.sendStatus(404);
return;
}
organisedData = getVoteOrganisedSponsorTimes(sponsorTimes, votes, UUIDs);
sponsorTimes = organisedData.sponsorTimes;
UUIDs = organisedData.UUIDs;
if (sponsorTimes.length == 0) {
res.sendStatus(404);
} else {
//send result
res.send({
sponsorTimes: sponsorTimes,
UUIDs: UUIDs
})
}
} catch(error) {
console.error(error);
res.send(500);
}
}

View file

@ -0,0 +1,33 @@
var db = require('../databases/databases.js').db;
var getHash = require('../utils/getHash.js');
module.exports = function getViewsForUser(req, res) {
let userID = req.query.userID;
if (userID == undefined) {
//invalid request
res.sendStatus(400);
return;
}
//hash the userID
userID = getHash(userID);
try {
let row = db.prepare("SELECT SUM(views) as viewCount FROM sponsorTimes WHERE userID = ?").get(userID);
//increase the view count by one
if (row.viewCount != null) {
res.send({
viewCount: row.viewCount
});
} else {
res.sendStatus(404);
}
} catch (err) {
console.log(err);
res.sendStatus(500);
return;
}
}

53
src/routes/setUsername.js Normal file
View file

@ -0,0 +1,53 @@
var config = require('../config.js');
var db = require('../databases/databases.js').db;
var getHash = require('../utils/getHash.js');
module.exports = function setUsername(req, res) {
let userID = req.query.userID;
let userName = req.query.username;
let adminUserIDInput = req.query.adminUserID;
if (userID == undefined || userName == undefined || userID === "undefined") {
//invalid request
res.sendStatus(400);
return;
}
if (adminUserIDInput != undefined) {
//this is the admin controlling the other users account, don't hash the controling account's ID
adminUserIDInput = getHash(adminUserIDInput);
if (adminUserIDInput != config.adminUserID) {
//they aren't the admin
res.sendStatus(403);
return;
}
} else {
//hash the userID
userID = getHash(userID);
}
try {
//check if username is already set
let row = db.prepare("SELECT count(*) as count FROM userNames WHERE userID = ?").get(userID);
if (row.count > 0) {
//already exists, update this row
db.prepare("UPDATE userNames SET userName = ? WHERE userID = ?").run(userName, userID);
} else {
//add to the db
db.prepare("INSERT INTO userNames VALUES(?, ?)").run(userID, userName);
}
res.sendStatus(200);
} catch (err) {
console.log(err);
res.sendStatus(500);
return;
}
}

View file

@ -0,0 +1,65 @@
var config = require('../config.js');
var databases = require('../databases/databases.js');
var db = databases.db;
var privateDB = databases.privateDB;
var getHash = require('../utils/getHash.js');
module.exports = async function shadowBanUser(req, res) {
let userID = req.query.userID;
let adminUserIDInput = req.query.adminUserID;
let enabled = req.query.enabled;
if (enabled === undefined){
enabled = true;
} else {
enabled = enabled === "true";
}
//if enabled is false and the old submissions should be made visible again
let unHideOldSubmissions = req.query.unHideOldSubmissions;
if (enabled === undefined){
unHideOldSubmissions = true;
} else {
unHideOldSubmissions = unHideOldSubmissions === "true";
}
if (adminUserIDInput == undefined || userID == undefined) {
//invalid request
res.sendStatus(400);
return;
}
//hash the userID
adminUserIDInput = getHash(adminUserIDInput);
if (adminUserIDInput !== config.adminUserID) {
//not authorized
res.sendStatus(403);
return;
}
//check to see if this user is already shadowbanned
let row = privateDB.prepare("SELECT count(*) as userCount FROM shadowBannedUsers WHERE userID = ?").get(userID);
if (enabled && row.userCount == 0) {
//add them to the shadow ban list
//add it to the table
privateDB.prepare("INSERT INTO shadowBannedUsers VALUES(?)").run(userID);
//find all previous submissions and hide them
db.prepare("UPDATE sponsorTimes SET shadowHidden = 1 WHERE userID = ?").run(userID);
} else if (!enabled && row.userCount > 0) {
//remove them from the shadow ban list
privateDB.prepare("DELETE FROM shadowBannedUsers WHERE userID = ?").run(userID);
//find all previous submissions and unhide them
if (unHideOldSubmissions) {
db.prepare("UPDATE sponsorTimes SET shadowHidden = 0 WHERE userID = ?").run(userID);
}
}
res.sendStatus(200);
}

View file

@ -0,0 +1,182 @@
var config = require('../config.js');
var databases = require('../databases/databases.js');
var db = databases.db;
var privateDB = databases.privateDB;
var YouTubeAPI = require('../utils/youtubeAPI.js');
var getHash = require('../utils/getHash.js');
var getIP = require('../utils/getIP.js');
var getFormattedTime = require('../utils/getFormattedTime.js');
// TODO: might need to be a util
//returns true if the user is considered trustworthy
//this happens after a user has made 5 submissions and has less than 60% downvoted submissions
async function isUserTrustworthy(userID) {
//check to see if this user how many submissions this user has submitted
let totalSubmissionsRow = db.prepare("SELECT count(*) as totalSubmissions, sum(votes) as voteSum FROM sponsorTimes WHERE userID = ?").get(userID);
if (totalSubmissionsRow.totalSubmissions > 5) {
//check if they have a high downvote ratio
let downvotedSubmissionsRow = db.prepare("SELECT count(*) as downvotedSubmissions FROM sponsorTimes WHERE userID = ? AND (votes < 0 OR shadowHidden > 0)").get(userID);
return (downvotedSubmissionsRow.downvotedSubmissions / totalSubmissionsRow.totalSubmissions) < 0.6 ||
(totalSubmissionsRow.voteSum > downvotedSubmissionsRow.downvotedSubmissions);
}
return true;
}
module.exports = async function submitSponsorTimes(req, res) {
let videoID = req.query.videoID;
let startTime = req.query.startTime;
let endTime = req.query.endTime;
let userID = req.query.userID;
//check if all correct inputs are here and the length is 1 second or more
if (videoID == undefined || startTime == undefined || endTime == undefined || userID == undefined
|| Math.abs(startTime - endTime) < 1) {
//invalid request
res.sendStatus(400);
return;
}
//hash the userID
userID = getHash(userID);
//hash the ip 5000 times so no one can get it from the database
let hashedIP = getHash(getIP(req) + config.globalSalt);
startTime = parseFloat(startTime);
endTime = parseFloat(endTime);
if (isNaN(startTime) || isNaN(endTime)) {
//invalid request
res.sendStatus(400);
return;
}
if (startTime === Infinity || endTime === Infinity) {
//invalid request
res.sendStatus(400);
return;
}
if (startTime > endTime) {
//time can't go backwards
res.sendStatus(400);
return;
}
try {
//check if this user is on the vip list
let vipRow = db.prepare("SELECT count(*) as userCount FROM vipUsers WHERE userID = ?").get(userID);
//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
let UUID = getHash(videoID + startTime + endTime + userID, 1);
//get current time
let timeSubmitted = Date.now();
let yesterday = timeSubmitted - 86400000;
//check to see if this ip has submitted too many sponsors today
let rateLimitCheckRow = privateDB.prepare("SELECT COUNT(*) as count FROM sponsorTimes WHERE hashedIP = ? AND videoID = ? AND timeSubmitted > ?").get([hashedIP, videoID, yesterday]);
if (rateLimitCheckRow.count >= 10) {
//too many sponsors for the same video from the same ip address
res.sendStatus(429);
} else {
//check to see if the user has already submitted sponsors for this video
let duplicateCheckRow = db.prepare("SELECT COUNT(*) as count FROM sponsorTimes WHERE userID = ? and videoID = ?").get([userID, videoID]);
if (duplicateCheckRow.count >= 8) {
//too many sponsors for the same video from the same user
res.sendStatus(429);
} else {
//check if this info has already been submitted first
let duplicateCheck2Row = db.prepare("SELECT UUID FROM sponsorTimes WHERE startTime = ? and endTime = ? and videoID = ?").get([startTime, endTime, videoID]);
//check to see if this user is shadowbanned
let shadowBanRow = privateDB.prepare("SELECT count(*) as userCount FROM shadowBannedUsers WHERE userID = ?").get(userID);
let shadowBanned = shadowBanRow.userCount;
if (!(await isUserTrustworthy(userID))) {
//hide this submission as this user is untrustworthy
shadowBanned = 1;
}
let startingVotes = 0;
if (vipRow.userCount > 0) {
//this user is a vip, start them at a higher approval rating
startingVotes = 10;
}
if (duplicateCheck2Row == null) {
//not a duplicate, execute query
try {
db.prepare("INSERT INTO sponsorTimes VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?)").run(videoID, startTime, endTime, startingVotes, UUID, userID, timeSubmitted, 0, shadowBanned);
//add to private db as well
privateDB.prepare("INSERT INTO sponsorTimes VALUES(?, ?, ?)").run(videoID, hashedIP, timeSubmitted);
res.sendStatus(200);
} catch (err) {
//a DB change probably occurred
res.sendStatus(502);
console.log("Error when putting sponsorTime in the DB: " + videoID + ", " + startTime + ", " + "endTime" + ", " + userID);
return;
}
} else {
res.sendStatus(409);
}
//check if they are a first time user
//if so, send a notification to discord
if (config.youtubeAPIKey !== null && config.discordFirstTimeSubmissionsWebhookURL !== null && duplicateCheck2Row == null) {
let userSubmissionCountRow = db.prepare("SELECT count(*) as submissionCount FROM sponsorTimes WHERE userID = ?").get(userID);
// If it is a first time submission
if (userSubmissionCountRow.submissionCount === 0) {
YouTubeAPI.videos.list({
part: "snippet",
id: videoID
}, function (err, data) {
if (err || data.items.length === 0) {
err && console.log(err);
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),
"color": 10813440,
"author": {
"name": userID
},
"thumbnail": {
"url": data.items[0].snippet.thumbnails.maxres ? data.items[0].snippet.thumbnails.maxres.url : "",
}
}]
}
});
});
}
}
}
}
} catch (err) {
console.error(err);
res.send(500);
}
}

View file

@ -0,0 +1,16 @@
var db = require('../databases/databases.js').db;
module.exports = function viewedVideoSponsorTime(req, res) {
let UUID = req.query.UUID;
if (UUID == undefined) {
//invalid request
res.sendStatus(400);
return;
}
//up the view count by one
db.prepare("UPDATE sponsorTimes SET views = views + 1 WHERE UUID = ?").run(UUID);
res.sendStatus(200);
}

View file

@ -0,0 +1,161 @@
var fs = require('fs');
var config = require('../config.js');
var getHash = require('../utils/getHash.js');
var getIP = require('../utils/getIP.js');
var getFormattedTime = require('../utils/getFormattedTime.js');
var databases = require('../databases/databases.js');
var db = databases.db;
var privateDB = databases.privateDB;
var YouTubeAPI = require('../utils/youtubeAPI.js');
var request = require('request');
module.exports = async function voteOnSponsorTime(req, res) {
let UUID = req.query.UUID;
let userID = req.query.userID;
let type = req.query.type;
if (UUID == undefined || userID == undefined || type == undefined) {
//invalid request
res.sendStatus(400);
return;
}
//hash the userID
let nonAnonUserID = getHash(userID);
userID = getHash(userID + UUID);
//x-forwarded-for if this server is behind a proxy
let ip = getIP(req);
//hash the ip 5000 times so no one can get it from the database
let hashedIP = getHash(ip + config.globalSalt);
try {
//check if vote has already happened
let votesRow = privateDB.prepare("SELECT type FROM votes WHERE userID = ? AND UUID = ?").get(userID, UUID);
//-1 for downvote, 1 for upvote. Maybe more depending on reputation in the future
let incrementAmount = 0;
let oldIncrementAmount = 0;
if (type == 1) {
//upvote
incrementAmount = 1;
} else if (type == 0) {
//downvote
incrementAmount = -1;
} else {
//unrecongnised type of vote
res.sendStatus(400);
return;
}
if (votesRow != undefined) {
if (votesRow.type == 1) {
//upvote
oldIncrementAmount = 1;
} else if (votesRow.type == 0) {
//downvote
oldIncrementAmount = -1;
} else if (votesRow.type == 2) {
//extra downvote
oldIncrementAmount = -4;
} else if (votesRow.type < 0) {
//vip downvote
oldIncrementAmount = votesRow.type;
}
}
//check if this user is on the vip list
let vipRow = db.prepare("SELECT count(*) as userCount FROM vipUsers WHERE userID = ?").get(nonAnonUserID);
//check if the increment amount should be multiplied (downvotes have more power if there have been many views)
let row = db.prepare("SELECT votes, views FROM sponsorTimes WHERE UUID = ?").get(UUID);
if (vipRow.userCount != 0 && incrementAmount < 0) {
//this user is a vip and a downvote
incrementAmount = - (row.votes + 2 - oldIncrementAmount);
type = incrementAmount;
} else if (row !== undefined && (row.votes > 8 || row.views > 15) && incrementAmount < 0) {
//increase the power of this downvote
incrementAmount = -Math.abs(Math.min(10, row.votes + 2 - oldIncrementAmount));
type = incrementAmount;
}
// Send discord message
if (type != 1) {
// Get video ID
let submissionInfoRow = db.prepare("SELECT videoID, userID, startTime, endTime FROM sponsorTimes WHERE UUID = ?").get(UUID);
let userSubmissionCountRow = db.prepare("SELECT count(*) as submissionCount FROM sponsorTimes WHERE userID = ?").get(nonAnonUserID);
if (config.youtubeAPIKey !== null && config.discordReportChannelWebhookURL !== null) {
YouTubeAPI.videos.list({
part: "snippet",
id: submissionInfoRow.videoID
}, function (err, data) {
if (err || data.items.length === 0) {
err && console.log(err);
return;
}
request.post(config.discordReportChannelWebhookURL, {
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\nSubmission ID: " + UUID +
"\n\nSubmitted by: " + submissionInfoRow.userID + "\n\nTimestamp: " +
getFormattedTime(submissionInfoRow.startTime) + " to " + getFormattedTime(submissionInfoRow.endTime),
"color": 10813440,
"author": {
"name": userSubmissionCountRow.submissionCount === 0 ? "Report by New User" : (vipRow.userCount !== 0 ? "Report by VIP User" : "")
},
"thumbnail": {
"url": data.items[0].snippet.thumbnails.maxres ? data.items[0].snippet.thumbnails.maxres.url : "",
}
}]
}
});
});
}
}
//update the votes table
if (votesRow != undefined) {
privateDB.prepare("UPDATE votes SET type = ? WHERE userID = ? AND UUID = ?").run(type, userID, UUID);
} else {
privateDB.prepare("INSERT INTO votes VALUES(?, ?, ?, ?)").run(UUID, userID, hashedIP, type);
}
//update the vote count on this sponsorTime
//oldIncrementAmount will be zero is row is null
db.prepare("UPDATE sponsorTimes SET votes = votes + ? WHERE UUID = ?").run(incrementAmount - oldIncrementAmount, UUID);
//for each positive vote, see if a hidden submission can be shown again
if (incrementAmount > 0) {
//find the UUID that submitted the submission that was voted on
let submissionUserID = db.prepare("SELECT userID FROM sponsorTimes WHERE UUID = ?").get(UUID).userID;
//check if any submissions are hidden
let hiddenSubmissionsRow = db.prepare("SELECT count(*) as hiddenSubmissions FROM sponsorTimes WHERE userID = ? AND shadowHidden > 0").get(submissionUserID);
if (hiddenSubmissionsRow.hiddenSubmissions > 0) {
//see if some of this users submissions should be visible again
if (await isUserTrustworthy(submissionUserID)) {
//they are trustworthy again, show 2 of their submissions again, if there are two to show
db.prepare("UPDATE sponsorTimes SET shadowHidden = 0 WHERE ROWID IN (SELECT ROWID FROM sponsorTimes WHERE userID = ? AND shadowHidden = 1 LIMIT 2)").run(submissionUserID)
}
}
}
//added to db
res.sendStatus(200);
} catch (err) {
console.error(err);
}
}

View file

@ -0,0 +1,13 @@
//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) {
//add a zero
secondsDisplay = "0" + secondsDisplay;
}
let formatted = minutes+ ":" + secondsDisplay;
return formatted;
}

12
src/utils/getHash.js Normal file
View file

@ -0,0 +1,12 @@
var crypto = require('crypto');
module.exports = function (value, times=5000) {
if (times <= 0) return "";
for (let i = 0; i < times; i++) {
let hashCreator = crypto.createHash('sha256');
value = hashCreator.update(value).digest('hex');
}
return value;
}

6
src/utils/getIP.js Normal file
View file

@ -0,0 +1,6 @@
var fs = require('fs');
var config = JSON.parse(fs.readFileSync('config.json'));
module.exports = function getIP(req) {
return config.behindProxy ? req.headers['x-forwarded-for'] : req.connection.remoteAddress;
}

9
src/utils/youtubeAPI.js Normal file
View file

@ -0,0 +1,9 @@
var config = require('../config.js');
// YouTube API
const YouTubeAPI = require("youtube-api");
YouTubeAPI.authenticate({
type: "key",
key: config.youtubeAPIKey
});
module.exports = YouTubeAPI;

35
test.js Normal file
View file

@ -0,0 +1,35 @@
var Mocha = require('mocha'),
fs = require('fs'),
path = require('path');
var createServer = require('./src/app.js');
var createMockServer = require('./test/mocks.js');
// Instantiate a Mocha instance.
var mocha = new Mocha();
var testDir = './test/cases'
// Add each .js file to the mocha instance
fs.readdirSync(testDir).filter(function(file) {
// Only keep the .js files
return file.substr(-3) === '.js';
}).forEach(function(file) {
mocha.addFile(
path.join(testDir, file)
);
});
var mockServer = createMockServer(() => {
console.log("Started mock HTTP Server");
var server = createServer(() => {
console.log("Started main HTTP server");
// Run the tests.
mocha.run(function(failures) {
mockServer.close();
server.close();
process.exitCode = failures ? 1 : 0; // exit with non-zero status if there were failures
});
});
});

17
test.json Normal file
View file

@ -0,0 +1,17 @@
{
"port": 8080,
"mockPort": 8081,
"globalSalt": "testSalt",
"adminUserID": "testUserId",
"youtubeAPIKey": "",
"discordReportChannelWebhookURL": "http://127.0.0.1:8081/ReportChannelWebhook",
"discordFirstTimeSubmissionsWebhookURL": "http://127.0.0.1:8081/FirstTimeSubmissionsWebhook",
"behindProxy": true,
"db": "./test/databases/sponsorTimes.db",
"privateDB": "./test/databases/private.db",
"createDatabaseIfNotExist": true,
"dbSchema": "./test/databases/_sponsorTimes.db.sql",
"privateDBSchema": "./test/databases/_private.db.sql",
"mode": "test",
"readOnly": false
}

29
test/cases/getHash.js Normal file
View file

@ -0,0 +1,29 @@
var getHash = require('../../src/utils/getHash.js');
var assert = require('assert');
describe('getHash', () => {
it('Should not output the input string', () => {
assert(getHash("test") !== "test");
assert(getHash("test", -1) !== "test");
assert(getHash("test", 0) !== "test");
assert(getHash("test", null) !== "test");
});
it('Should return a hashed value', () => {
assert.equal(getHash("test"), "2f327ef967ade1ebf4319163f7debbda9cc17bb0c8c834b00b30ca1cf1c256ee");
});
it ('Should take a variable number of passes', () => {
assert.equal(getHash("test", 1), "9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08");
assert.equal(getHash("test", 2), "7b3d979ca8330a94fa7e9e1b466d8b99e0bcdea1ec90596c0dcc8d7ef6b4300c");
assert.equal(getHash("test", 3), "5b24f7aa99f1e1da5698a4f91ae0f4b45651a1b625c61ed669dd25ff5b937972");
});
it ('Should default to 5000 passes', () => {
assert.equal(getHash("test"), getHash("test", 5000));
});
it ('Should not take a negative number of passes', () => {
assert.equal(getHash("test", -1), "");
});
});

View file

@ -0,0 +1,53 @@
var request = require('request');
var db = require('../../src/databases/databases.js').db;
var utils = require('../utils.js');
/*
*CREATE TABLE IF NOT EXISTS "sponsorTimes" (
"videoID" TEXT NOT NULL,
"startTime" REAL NOT NULL,
"endTime" REAL NOT NULL,
"votes" INTEGER NOT NULL,
"UUID" TEXT NOT NULL UNIQUE,
"userID" TEXT NOT NULL,
"timeSubmitted" INTEGER NOT NULL,
"views" INTEGER NOT NULL,
"shadowHidden" INTEGER NOT NULL
);
*/
describe('getVideoSponsorTime', () => {
before(() => {
db.exec("INSERT INTO sponsorTimes VALUES ('testtesttest', 1, 11, 2, 'abc123', 'testman', 0, 50, 0)");
});
it('Should be able to get a time', (done) => {
request.get(utils.getbaseURL()
+ "/api/getVideoSponsorTimes?videoID=testtesttest", null,
(err, res, body) => {
if (err) done(false);
else if (res.statusCode !== 200) done("non 200");
else done();
});
});
it('Should be able to get the correct time', (done) => {
request.get(utils.getbaseURL()
+ "/api/getVideoSponsorTimes?videoID=testtesttest", null,
(err, res, body) => {
if (err) done(false);
else if (res.statusCode !== 200) done("non 200");
else {
let parsedBody = JSON.parse(body);
if (parsedBody.sponsorTimes[0][0] === 1
&& parsedBody.sponsorTimes[0][1] === 11
&& parsedBody.UUIDs[0] === 'abc123') {
done();
} else {
done("Wrong data was returned + " + parsedBody);
}
};
});
});
});

View file

@ -0,0 +1,26 @@
var assert = require('assert');
var request = require('request');
var utils = require('../utils.js');
describe('postVideoSponsorTime', () => {
it('Should be able to create a time', (done) => {
request.get(utils.getbaseURL()
+ "/api/postVideoSponsorTimes?videoID=djgofQKWmXc&startTime=1&endTime=10&userID=test", null,
(err, res, body) => {
if (err) done(false);
else if (res.statusCode === 200) done();
else done(false);
});
});
it('Should return 400 for missing params', (done) => {
request.get(utils.getbaseURL()
+ "/api/postVideoSponsorTimes?startTime=1&endTime=10&userID=test", null,
(err, res, body) => {
if (err) done(false);
if (res.statusCode === 400) done();
else done(false);
});
});
});

View file

@ -0,0 +1,22 @@
BEGIN TRANSACTION;
DROP TABLE IF EXISTS "shadowBannedUsers";
DROP TABLE IF EXISTS "votes";
DROP TABLE IF EXISTS "sponsorTimes";
CREATE TABLE IF NOT EXISTS "shadowBannedUsers" (
"userID" TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS "votes" (
"UUID" TEXT NOT NULL,
"userID" INTEGER NOT NULL,
"hashedIP" INTEGER NOT NULL,
"type" INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS "sponsorTimes" (
"videoID" TEXT NOT NULL,
"hashedIP" TEXT NOT NULL,
"timeSubmitted" INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS sponsorTimes_hashedIP on sponsorTimes(hashedIP);
CREATE INDEX IF NOT EXISTS votes_userID on votes(UUID);
COMMIT;

View file

@ -0,0 +1,26 @@
BEGIN TRANSACTION;
DROP TABLE IF EXISTS "vipUsers";
DROP TABLE IF EXISTS "sponsorTimes";
DROP TABLE IF EXISTS "userNames";
CREATE TABLE IF NOT EXISTS "vipUsers" (
"userID" TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS "sponsorTimes" (
"videoID" TEXT NOT NULL,
"startTime" REAL NOT NULL,
"endTime" REAL NOT NULL,
"votes" INTEGER NOT NULL,
"UUID" TEXT NOT NULL UNIQUE,
"userID" TEXT NOT NULL,
"timeSubmitted" INTEGER NOT NULL,
"views" INTEGER NOT NULL,
"shadowHidden" INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS "userNames" (
"userID" TEXT NOT NULL,
"userName" TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS sponsorTimes_videoID on sponsorTimes(videoID);
CREATE INDEX IF NOT EXISTS sponsorTimes_UUID on sponsorTimes(UUID);
COMMIT;

16
test/mocks.js Normal file
View file

@ -0,0 +1,16 @@
var express = require('express');
var app = express();
var config = require('../src/config.js');
app.post('/ReportChannelWebhook', (req, res) => {
res.status(200);
});
app.post('/FirstTimeSubmissionsWebhook', (req, res) => {
res.status(200);
});
module.exports = function createMockServer(callback) {
return app.listen(config.mockPort, callback);
}

7
test/utils.js Normal file
View file

@ -0,0 +1,7 @@
var config = require('../src/config.js');
module.exports = {
getbaseURL: () => {
return "http://localhost:" + config.port;
}
};