Compare commits

...

17 Commits

Author SHA1 Message Date
Dessa Simpson dd423bdb34 Fix empty subscriptions page crash
Fix issue where when Twitch returns an empty page of subscribers, the
app crashes due to trying to get response.data[0].user_id where
response.data[0] is undefined

Also adds some debug logging
2022-02-28 09:34:18 -07:00
Dessa Simpson ac953b0bb5 Uncomment debug lines 2022-02-27 17:36:55 -07:00
Dessa Simpson 90f672b288 Bump version to v0.8 2022-02-27 17:36:50 -07:00
Dessa Simpson 8b49d51e08 Trim whitespace from URL on client side
Fixes #32
2021-06-06 23:20:47 -07:00
Dessa Simpson 7897fe6f3d Add ratelimits
Fixes #10
2021-06-06 23:11:30 -07:00
Dessa Simpson 6e26650077 Merge remote-tracking branch 'origin/master' 2021-04-25 22:17:24 -07:00
Dessa Simpson 891ccfae92 Update 'RELEASE.md' 2021-04-26 05:16:43 +00:00
Dessa Simpson fc09fd4dd4 Bump version to v0.7 2021-04-25 22:15:38 -07:00
Dessa Simpson 347a6d2324 Add comma in v0.6-v0.7.sql 2021-04-25 22:09:16 -07:00
Dessa Simpson 632dc73ee9 Add database logic to clear out requests that have 0 votes
Also, clean up some db constraints.

Fixes #29
2021-04-25 20:42:20 -07:00
Dessa Simpson 8483705ae4 Add sorting to UI
Fixes #27
2021-04-25 09:02:07 -07:00
Dessa Simpson 5e42236354 Misc. bugfixes
- Fix duplicate request message showing undefined for requester and 
state
- Fix add request dialog not closing properly
2021-04-24 21:19:25 -07:00
Dessa Simpson 6782ad2fb4 Apply api dupe workaround to subscriptions endpoint 2021-04-24 13:27:26 -07:00
Dessa Simpson e4910f87d1 Add missing semicolons in db bootstrap code 2021-03-27 16:59:43 -07:00
Dessa Simpson 28d5ce09dd Add sorting to API 2021-02-26 23:20:49 -07:00
Dessa Simpson b968f054d5 Bump version to v0.6 2021-02-25 19:56:14 -07:00
Dessa Simpson 84a22ccffd Add pagination
Note: Requests API endpoints /getRequests and /getRequestsAll have 
breaking changes (the response schema changed entirely).

Fixes #26
2021-02-25 19:52:57 -07:00
24 changed files with 385 additions and 103 deletions

View File

@ -12,9 +12,19 @@ If the answer to any of the above is yes, then the release MUST be a major or mi
- [ ] Add a commit which adds a database upgrade script at `db/upgrade/[oldversion]-[newversion].sql` - [ ] Add a commit which adds a database upgrade script at `db/upgrade/[oldversion]-[newversion].sql`
- ALWAYS use a transaction for the entirety of this file - ALWAYS use a transaction for the entirety of this file
- At minimum, DB version must be bumped - At minimum, DB version must be bumped
- [ ] Add a commit which bumps the version in `app/version.ts` and `db/00-version.sql` - Test the upgrade script as follows:
```
git checkout [previous release tag]
docker-compose down
docker-compose up
psql -h 0 -U postgres < db/upgrade/v[previous]-v[current].sql
```
Update the version in `src/version.ts` and verify the app works as expected.
- [ ] Add a commit which bumps the version in `src/version.ts` and `db/00-version.sql`, entitled `Bump version to vMAJOR.MINOR`
- [ ] Tag the latest commit with `vMAJOR.MINOR` - [ ] Tag the latest commit with `vMAJOR.MINOR`
- [ ] Write release notes
## Patch Releases ## Patch Releases
- [ ] Add a commit which bumps the patch level in `app/version.ts` - [ ] Add a commit which bumps the patch level in `src/version.ts`, entitled `Bump version to vMAJOR.MINOR.PATCH`
- [ ] Tag the latest commit with `vMAJOR.MINOR.PATCH` - [ ] Tag the latest commit with `vMAJOR.MINOR.PATCH`
- [ ] Write release notes

View File

@ -8,4 +8,4 @@ CREATE OR REPLACE FUNCTION get_version() RETURNS VARCHAR
AS $$SELECT major || '.' || minor FROM version $$ AS $$SELECT major || '.' || minor FROM version $$
LANGUAGE SQL; LANGUAGE SQL;
INSERT INTO version (major,minor) VALUES (0,5); INSERT INTO version (major,minor) VALUES (0,8);

View File

@ -1,12 +1,14 @@
CREATE TABLE config ( CREATE TABLE config (
rowlock bool DEFAULT TRUE UNIQUE NOT NULL CHECK (rowlock = TRUE), rowlock bool DEFAULT TRUE UNIQUE NOT NULL CHECK (rowlock = TRUE),
normaluservotepoints int NOT NULL, normaluservotepoints int NOT NULL DEFAULT 10,
followervotepoints int NOT NULL, followervotepoints int NOT NULL DEFAULT 50,
subscribervotepoints int NOT NULL, subscribervotepoints int NOT NULL DEFAULT 100,
title varchar NOT NULL, normaluserratelimit int NOT NULL DEFAULT 1,
colors jsonb NOT NULL, followerratelimit int NOT NULL DEFAULT 2,
subscriberratelimit int NOT NULL DEFAULT 3,
title varchar NOT NULL DEFAULT '{username}''s Learn Request Queue',
colors jsonb NOT NULL DEFAULT '{"bg": {"primary": "#444444","table": "#282828","navbar": "#666666","error": "#ff0000"},"fg": {"primary": "#dddddd","ahover": "#ffffff","title": "#eeeeee"}}',
PRIMARY KEY (rowLock) PRIMARY KEY (rowLock)
); );
INSERT INTO config (normalUserVotePoints,followerVotePoints,subscriberVotePoints,title,colors) INSERT INTO config (rowlock) VALUES (true);
VALUES (10,50,100,'{username}''s Learn Request Queue','{"bg": {"primary": "#444444","table": "#282828","navbar": "#666666","error": "#ff0000"},"fg": {"primary": "#dddddd","ahover": "#ffffff","title": "#eeeeee"}}');

View File

@ -3,4 +3,4 @@ CREATE TABLE streamer (
tokenPair json, tokenPair json,
PRIMARY KEY (userid), PRIMARY KEY (userid),
FOREIGN KEY (userid) REFERENCES users(userid) FOREIGN KEY (userid) REFERENCES users(userid)
) );

View File

@ -1,5 +1,5 @@
CREATE TABLE requests ( CREATE TABLE requests (
url varchar NOT NULL UNIQUE, url varchar NOT NULL,
requester int NOT NULL, requester int NOT NULL,
state varchar NOT NULL DEFAULT 'Requested', state varchar NOT NULL DEFAULT 'Requested',
reqTimestamp timestamptz NOT NULL DEFAULT NOW(), reqTimestamp timestamptz NOT NULL DEFAULT NOW(),

View File

@ -1,6 +1,6 @@
CREATE TABLE votes ( CREATE TABLE votes (
requestUrl varchar, requestUrl varchar NOT NULL,
userId int, userId int NOT NULL,
PRIMARY KEY (requestUrl,userId), PRIMARY KEY (requestUrl,userId),
FOREIGN KEY (requestUrl) REFERENCES requests(url) ON DELETE CASCADE, FOREIGN KEY (requestUrl) REFERENCES requests(url) ON DELETE CASCADE,
FOREIGN KEY (userId) REFERENCES users(userId) ON DELETE CASCADE FOREIGN KEY (userId) REFERENCES users(userId) ON DELETE CASCADE

View File

@ -1,4 +1,4 @@
CREATE TABLE bans ( CREATE TABLE bans (
userid integer, userid integer,
PRIMARY KEY (userid) PRIMARY KEY (userid)
) );

View File

@ -1,4 +1,4 @@
CREATE TABLE follows ( CREATE TABLE follows (
userid integer, userid integer,
PRIMARY KEY (userid) PRIMARY KEY (userid)
) );

View File

@ -1,4 +1,4 @@
CREATE TABLE subscriptions ( CREATE TABLE subscriptions (
userid integer, userid integer,
PRIMARY KEY (userid) PRIMARY KEY (userid)
) );

View File

@ -60,3 +60,22 @@ CREATE OR REPLACE VIEW streamer_user_vw AS
SELECT users.userid as userid, users.displayname as displayname, users.imageurl as imageurl FROM streamer SELECT users.userid as userid, users.displayname as displayname, users.imageurl as imageurl FROM streamer
LEFT JOIN users LEFT JOIN users
ON streamer.userid = users.userid; ON streamer.userid = users.userid;
CREATE OR REPLACE VIEW ratelimit_vw AS
SELECT users.userid,COALESCE(count,0),ratelimit.reqcount AS max,COALESCE(count,0) >= ratelimit.reqcount AS status
FROM users
LEFT JOIN (SELECT requester,COUNT(url)
FROM requests
WHERE reqtimestamp > (now() - '24 hours'::interval)
GROUP BY requests.requester
) AS requests ON users.userid = requests.requester
LEFT JOIN follows ON requests.requester = follows.userid
LEFT JOIN subscriptions ON requests.requester = subscriptions.userid
CROSS JOIN config
CROSS JOIN LATERAL (VALUES (
CASE
WHEN follows.userid IS NULL AND subscriptions.userid IS NULL THEN config.normaluserratelimit
WHEN follows.userid IS NOT NULL AND subscriptions.userid IS NULL THEN config.followerratelimit
WHEN subscriptions.userid IS NOT NULL THEN config.subscriberratelimit
END
)) AS ratelimit(reqcount);

View File

@ -16,6 +16,13 @@ CREATE OR REPLACE PROCEDURE add_request(url varchar,requester int)
CALL update_scores(); CALL update_scores();
$$; $$;
CREATE OR REPLACE PROCEDURE clear_zero_votes()
LANGUAGE SQL
AS $$
DELETE FROM requests WHERE NOT EXISTS
(SELECT FROM votes WHERE requests.url = votes.requesturl);
$$;
CREATE OR REPLACE PROCEDURE add_vote(url varchar,voteuser int) CREATE OR REPLACE PROCEDURE add_vote(url varchar,voteuser int)
LANGUAGE SQL LANGUAGE SQL
AS $$ AS $$
@ -28,6 +35,7 @@ CREATE OR REPLACE PROCEDURE delete_vote(url varchar,voteuser int)
AS $$ AS $$
DELETE FROM votes WHERE requesturl = url AND userid = voteuser; DELETE FROM votes WHERE requesturl = url AND userid = voteuser;
CALL update_scores(); CALL update_scores();
CALL clear_zero_votes();
$$; $$;
CREATE OR REPLACE PROCEDURE update_request_score_modifier(updateurl varchar, scoreDiff int) CREATE OR REPLACE PROCEDURE update_request_score_modifier(updateurl varchar, scoreDiff int)

5
db/upgrade/v0.5-v0.6.sql Normal file
View File

@ -0,0 +1,5 @@
BEGIN;
UPDATE version SET minor = 6;
COMMIT;

24
db/upgrade/v0.6-v0.7.sql Normal file
View File

@ -0,0 +1,24 @@
BEGIN;
UPDATE version SET minor = 7;
ALTER TABLE votes
ALTER COLUMN requestUrl SET NOT NULL,
ALTER COLUMN userId SET NOT NULL;
CREATE OR REPLACE PROCEDURE clear_zero_votes()
LANGUAGE SQL
AS $$
DELETE FROM requests WHERE NOT EXISTS
(SELECT FROM votes WHERE requests.url = votes.requesturl);
$$;
CREATE OR REPLACE PROCEDURE delete_vote(url varchar,voteuser int)
LANGUAGE SQL
AS $$
DELETE FROM votes WHERE requesturl = url AND userid = voteuser;
CALL update_scores();
CALL clear_zero_votes();
$$;
COMMIT;

35
db/upgrade/v0.7-v0.8.sql Normal file
View File

@ -0,0 +1,35 @@
BEGIN;
UPDATE version SET minor = 8;
ALTER TABLE config
ALTER COLUMN normaluservotepoints SET DEFAULT 10,
ALTER COLUMN followervotepoints SET DEFAULT 50,
ALTER COLUMN subscribervotepoints SET DEFAULT 100,
ALTER COLUMN title SET DEFAULT '{username}''s Learn Request Queue',
ALTER COLUMN colors SET DEFAULT '{"bg": {"primary": "#444444","table": "#282828","navbar": "#666666","error": "#ff0000"},"fg": {"primary": "#dddddd","ahover": "#ffffff","title": "#eeeeee"}}',
ADD COLUMN normaluserratelimit int NOT NULL DEFAULT 1,
ADD COLUMN followerratelimit int NOT NULL DEFAULT 2,
ADD COLUMN subscriberratelimit int NOT NULL DEFAULT 3;
CREATE OR REPLACE VIEW ratelimit_vw AS
SELECT users.userid,COALESCE(count,0),ratelimit.reqcount AS max,COALESCE(count,0) >= ratelimit.reqcount AS status
FROM users
LEFT JOIN (SELECT requester,COUNT(url)
FROM requests
WHERE reqtimestamp > (now() - '24 hours'::interval)
GROUP BY requests.requester
) AS requests ON users.userid = requests.requester
LEFT JOIN follows ON requests.requester = follows.userid
LEFT JOIN subscriptions ON requests.requester = subscriptions.userid
CROSS JOIN config
CROSS JOIN LATERAL (VALUES (
CASE
WHEN follows.userid IS NULL AND subscriptions.userid IS NULL THEN config.normaluserratelimit
WHEN follows.userid IS NOT NULL AND subscriptions.userid IS NULL THEN config.followerratelimit
WHEN subscriptions.userid IS NOT NULL THEN config.subscriberratelimit
END
)) AS ratelimit(reqcount);
COMMIT;

View File

@ -1,29 +1,52 @@
var requestsDiv = document.getElementById("requests"); var requestsDiv = document.getElementById("requests");
var cronJobs = ['processBans'] var cronJobs = ['processBans'];
var currentPage = 1;
var totalPages = 1;
var count = document.getElementById("count").value;
var sortBy = document.getElementById("sortBy").value;
var sortDir = "desc";
function getRequests(count,allRequests) { function getRequests(offset,allRequests) {
var reqUrl; if (allRequests) var reqUrl = "/api/getAllRequests";
if (allRequests) { else var reqUrl = "/api/getRequests";
reqUrl = "/api/getAllRequests"; reqUrl += `?count=${count}&offset=${offset}&sort=${sortBy}&sortDirection=${sortDir}`;
} else {
reqUrl = "/api/getRequests";
}
reqUrl += `?count=${count}`;
fetch(reqUrl) fetch(reqUrl)
.then(response => response.json()) .then(response => response.json())
.then(requests => { .then(requests => {
window.requests = requests; buildTable(requests);
buildTable();
}); });
} }
function buildTable() { function buildTable(requests) {
var requestsDivHTML = '<table><tr><th class="request-link">Song</th><th class="request-requester">Requester</th><th class="request-score">Score</th>'; totalPages = Math.ceil(requests.total/document.getElementById("count").value);
document.getElementById("totalPages").innerText = totalPages;
if (currentPage <= 1) {
currentPage = 1;
document.getElementById("pageBtnFirst").disabled = true;
document.getElementById("pageBtnPrev").disabled = true;
} else {
document.getElementById("pageBtnFirst").disabled = false;
document.getElementById("pageBtnPrev").disabled = false;
}
if (currentPage >= totalPages) {
currentPage = totalPages;
document.getElementById("pageBtnLast").disabled = true;
document.getElementById("pageBtnNext").disabled = true;
} else {
document.getElementById("pageBtnLast").disabled = false;
document.getElementById("pageBtnNext").disabled = false;
}
document.getElementById("page").innerHTML = "";
for (i = 1; i <= totalPages; i++) {
document.getElementById("page").innerHTML += `<option value=${i}>${i}</option>`;
}
document.getElementById("page").value = currentPage;
var requestsDivHTML = '<table><tr><th class="request-link">Song Title</th><th class="request-requester">Requester</th><th class="request-score">Score</th>';
requestsDivHTML += '<th class="request-state">State</td>'; requestsDivHTML += '<th class="request-state">State</td>';
if (window.loggedIn) requestsDivHTML += '<th class="request-vote">Vote</td>'; if (window.loggedIn) requestsDivHTML += '<th class="request-vote">Vote</td>';
if (window.isStreamer) requestsDivHTML += '<th class="request-update">Update</th>' if (window.isStreamer) requestsDivHTML += '<th class="request-update">Update</th>'
requestsDivHTML += "</tr>"; requestsDivHTML += "</tr>";
for (request of requests) { for (request of requests.requests) {
requestsDivHTML += `<tr><td class="request-link"><a href="${request.url}" target="_blank">${request.title}</a></td>\ requestsDivHTML += `<tr><td class="request-link"><a href="${request.url}" target="_blank">${request.title}</a></td>\
<td class="request-requester">${request.imageurl ? `<img src="${request.imageurl}" class="table-userpic"/>` : ''}${request.requester}</td>\ <td class="request-requester">${request.imageurl ? `<img src="${request.imageurl}" class="table-userpic"/>` : ''}${request.requester}</td>\
<td class="request-score">${request.score}</td>`; <td class="request-score">${request.score}</td>`;
@ -45,12 +68,14 @@ function buildTable() {
} }
function updateTable() { function updateTable() {
allRequests = document.getElementById("allRequests").checked; var offset = (currentPage - 1) * count;
getRequests(document.getElementById("count").value,allRequests); var allRequests = document.getElementById("allRequests").checked;
getRequests(offset,allRequests);
} }
function applyUrlTransforms(url) { function applyUrlTransforms(url) {
console.log("Begin applyUrlTransforms:" + url); console.log("Begin applyUrlTransforms:" + url);
url = url.trim();
if (url.match(/^https?:\/\/(www\.)?youtu(\.be|be\.com)\//)) { // Youtube if (url.match(/^https?:\/\/(www\.)?youtu(\.be|be\.com)\//)) { // Youtube
console.log("Youtube"); console.log("Youtube");
var videoid = ""; var videoid = "";
@ -66,6 +91,11 @@ function applyUrlTransforms(url) {
} }
} }
function goToPage(page) {
currentPage = parseInt(page,10);
updateTable();
}
function getColorObject() { function getColorObject() {
return { return {
bg: { bg: {
@ -206,7 +236,7 @@ function validateAndSubmitRequest() {
updateTable(); updateTable();
document.getElementById("addRequestUrl").value = ""; document.getElementById("addRequestUrl").value = "";
response.text().then((message) => { response.text().then((message) => {
closeAddRequestModal(); closeAllModals();
showMessage(message); showMessage(message);
}); });
}); });
@ -348,5 +378,16 @@ for(state of validStates) {
var opt = document.createElement("option"); var opt = document.createElement("option");
opt.text = state; opt.text = state;
opt.value = state; opt.value = state;
updateRequestStateSelect.add(opt) updateRequestStateSelect.add(opt);
}
function toggleSortDir() {
if (window.sortDir == "desc") {
document.getElementById("sortDir").innerText = "↑";
window.sortDir = "asc";
} else {
document.getElementById("sortDir").innerText = "↓";
window.sortDir = "desc";
}
updateTable();
} }

View File

@ -111,6 +111,20 @@ div#nav-userpic {
text-align: right; text-align: right;
} }
.tableSettings {
display: flex;
align-items: center;
justify-content: space-between;
}
.tableSettings > span {
margin: auto;
}
#tableSettingsTop {
margin-bottom: 10px;
}
#modalBackground { #modalBackground {
display: none; display: none;
position: fixed; position: fixed;
@ -123,6 +137,7 @@ div#nav-userpic {
height: 100%; height: 100%;
background-color: #444; background-color: #444;
background-color: #444a; background-color: #444a;
margin: 5px;
} }
.modal { .modal {
@ -196,3 +211,7 @@ div#nav-userpic {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
} }
#sortDir {
padding: 0;
}

View File

@ -42,31 +42,85 @@ app.use(session({
// API // API
app.get("/api/getRequests", async (request, response) => { app.get("/api/getRequests", async (request, response) => {
if (!request.session) { if (!request.session) throw new Error ("Missing request.session");
throw new Error ("Missing request.session")
}
var requestCount = ( request.query.count ? parseInt(request.query.count as string, 10) : 5 );
await validateApiToken(request.session); await validateApiToken(request.session);
var requestCount = ( request.query.count ? parseInt(request.query.count as string, 10) : 5 );
var requestOffset = ( request.query.offset ? parseInt(request.query.offset as string, 10) : 0 );
var sortDirection = ( request.query.sortDirection == "asc" ? "ASC" : "DESC" );
var inverseSortDirection = ( sortDirection == "ASC" ? "DESC" : "ASC" );
switch (request.query.sort) {
case undefined: // Default sort by newest
case "timestamp":
var requestSort = `reqTimestamp ${sortDirection}`;
break;
case "score":
var requestSort = `score ${sortDirection}, reqTimestamp ${inverseSortDirection}`;
break;
case "title":
var requestSort = `title ${sortDirection}`
break;
case "requester":
var requestSort = `requester ${sortDirection}, title ${sortDirection}`
break;
default:
response.status(400);
response.send("Invalid sort");
return;
};
var requestsTotal = await requests.getRequestsTotal();
if (request.session.user) { if (request.session.user) {
requests.getRequestsVoted(requestCount,request.session.user.id).then((val: Array<any>) => response.send(val)) requests.getRequestsVoted(requestCount,requestOffset,requestSort,request.session.user.id)
.then((val: Array<any>) => response.send({
total: requestsTotal,
requests: val
}))
.catch((e: any) => errorHandler(request,response,e)); .catch((e: any) => errorHandler(request,response,e));
} else { } else {
requests.getRequests(requestCount).then((val: Array<any>) => response.send(val)) requests.getRequests(requestCount,requestOffset,requestSort)
.then((val: Array<any>) => response.send({
total: requestsTotal,
requests: val
}))
.catch((e: any) => errorHandler(request,response,e)); .catch((e: any) => errorHandler(request,response,e));
} }
}); });
app.get("/api/getAllRequests", async (request, response) => { app.get("/api/getAllRequests", async (request, response) => {
if (!request.session) { if (!request.session) throw new Error ("Missing request.session");
throw new Error ("Missing request.session")
}
var requestCount = ( request.query.count ? parseInt(request.query.count as string, 10) : 5 );
await validateApiToken(request.session); await validateApiToken(request.session);
var requestCount = ( request.query.count ? parseInt(request.query.count as string, 10) : 5 );
var requestOffset = ( request.query.offset ? parseInt(request.query.offset as string, 10) : 0 );
var sortDirection = ( request.query.sortDirection == "asc" ? "ASC" : "DESC" );
switch (request.query.sort) {
case undefined:
case "score":
var requestSort = `score ${sortDirection}, reqTimestamp ASC`;
break;
case "timestamp":
var requestSort = `reqTimestamp ${sortDirection}`;
break;
case "alpha":
var requestSort = `title ${sortDirection}`
break;
default:
response.status(400);
response.send("Invalid sort");
return;
}
var requestsTotal = await requests.getAllRequestsTotal();
if (request.session.user) { if (request.session.user) {
requests.getAllRequestsVoted(requestCount,request.session.user.id).then((val: Array<any>) => response.send(val)) requests.getAllRequestsVoted(requestCount,requestOffset,requestSort,request.session.user.id)
.then((val: Array<any>) => response.send({
total: requestsTotal,
requests: val
}))
.catch((e: any) => errorHandler(request,response,e)); .catch((e: any) => errorHandler(request,response,e));
} else { } else {
requests.getAllRequests(requestCount).then((val: Array<any>) => response.send(val)) requests.getAllRequests(requestCount,requestOffset,requestSort)
.then((val: Array<any>) => response.send({
total: requestsTotal,
requests: val
}))
.catch((e: any) => errorHandler(request,response,e)); .catch((e: any) => errorHandler(request,response,e));
} }
}); });
@ -79,7 +133,8 @@ app.post("/api/addRequest", async (request, response) => {
response.send("Session expired; please log in again"); response.send("Session expired; please log in again");
return; return;
} }
var banned = await db.query(Object.assign(queries.checkBan, { values: [request.session.user.id] })).then((result: pg.QueryResult) => result.rowCount > 0); var banned = await db.query(Object.assign(queries.checkBan, { values: [request.session.user.id] }))
.then((result: pg.QueryResult) => result.rowCount > 0);
if (banned) { if (banned) {
response.status(401); response.status(401);
response.send("You are banned; you may not add new requests."); response.send("You are banned; you may not add new requests.");

View File

@ -9,8 +9,8 @@ export async function processBans(streamer: twitch.StreamerUserIdTokenPair) {
await dbconn.query("DELETE FROM bans"); await dbconn.query("DELETE FROM bans");
var response = await twitch.streamerApiRequest(streamer, var response = await twitch.streamerApiRequest(streamer,
`/moderation/banned?broadcaster_id=${streamer.userid}&first=100`); `/moderation/banned?broadcaster_id=${streamer.userid}&first=100`);
//log(LogLevel.DEBUG,"Ban API response:") log(LogLevel.DEBUG,"Ban API response:")
//log(LogLevel.DEBUG,JSON.stringify(response,null,2)); log(LogLevel.DEBUG,JSON.stringify(response,null,2));
while (true) { while (true) {
var insertBanQuery = "INSERT INTO bans (userid) VALUES "; var insertBanQuery = "INSERT INTO bans (userid) VALUES ";
var banRow = 0; // Used for $1, $2, etc. in parameterized query var banRow = 0; // Used for $1, $2, etc. in parameterized query
@ -37,8 +37,8 @@ export async function processBans(streamer: twitch.StreamerUserIdTokenPair) {
var oldFirstUserid = response.data[0].user_id; var oldFirstUserid = response.data[0].user_id;
response = await twitch.streamerApiRequest(streamer, response = await twitch.streamerApiRequest(streamer,
`/moderation/banned?broadcaster_id=${streamer.userid}&after=${response.pagination.cursor}&first=100`); `/moderation/banned?broadcaster_id=${streamer.userid}&after=${response.pagination.cursor}&first=100`);
//log(LogLevel.DEBUG,"Ban API response:"); log(LogLevel.DEBUG,"Ban API response:");
//log(LogLevel.DEBUG,JSON.stringify(response,null,2)); log(LogLevel.DEBUG,JSON.stringify(response,null,2));
// Work around broken api endpoint giving a cursor referring to the // Work around broken api endpoint giving a cursor referring to the
// current page, causing an infinite loop // current page, causing an infinite loop
if (oldFirstUserid == response.data[0].user_id) break; if (oldFirstUserid == response.data[0].user_id) break;

View File

@ -9,8 +9,8 @@ export async function processSubscriptions(streamer: twitch.StreamerUserIdTokenP
await dbconn.query("DELETE FROM subscriptions"); await dbconn.query("DELETE FROM subscriptions");
var response = await twitch.streamerApiRequest(streamer, var response = await twitch.streamerApiRequest(streamer,
`/subscriptions?broadcaster_id=${streamer.userid}&first=100`); `/subscriptions?broadcaster_id=${streamer.userid}&first=100`);
//log(LogLevel.DEBUG,"Subscriptions API response:") log(LogLevel.DEBUG,"Subscriptions API response:")
//log(LogLevel.DEBUG,JSON.stringify(response,null,2)); log(LogLevel.DEBUG,JSON.stringify(response,null,2));
while (true) { while (true) {
var insertSubscriptionQuery = "INSERT INTO subscriptions (userid) VALUES "; var insertSubscriptionQuery = "INSERT INTO subscriptions (userid) VALUES ";
var subscriptionRow = 0; // Used for $1, $2, etc. in parameterized query var subscriptionRow = 0; // Used for $1, $2, etc. in parameterized query
@ -22,6 +22,7 @@ export async function processSubscriptions(streamer: twitch.StreamerUserIdTokenP
subscriptionsArray.push(subscription.user_id as number); subscriptionsArray.push(subscription.user_id as number);
} }
insertSubscriptionQuery = insertSubscriptionQuery.slice(0,-2); // Cut last `, ` off of the end insertSubscriptionQuery = insertSubscriptionQuery.slice(0,-2); // Cut last `, ` off of the end
insertSubscriptionQuery += " ON CONFLICT DO NOTHING"; // Deal with broken endpoint returning dupes
var subscriptionQueryConfig = { var subscriptionQueryConfig = {
text: insertSubscriptionQuery, text: insertSubscriptionQuery,
values: subscriptionsArray values: subscriptionsArray
@ -31,11 +32,12 @@ export async function processSubscriptions(streamer: twitch.StreamerUserIdTokenP
await dbconn.query(subscriptionQueryConfig); await dbconn.query(subscriptionQueryConfig);
} }
if (response.pagination.cursor) { if (response.pagination.cursor) {
//var oldFirstUserid = response.data[0].user_id; var oldFirstUserid = response.data[0].user_id;
response = await twitch.streamerApiRequest(streamer, response = await twitch.streamerApiRequest(streamer,
`/subscriptions?broadcaster_id=${streamer.userid}&after=${response.pagination.cursor}&first=100`); `/subscriptions?broadcaster_id=${streamer.userid}&after=${response.pagination.cursor}&first=100`);
//log(LogLevel.DEBUG,"Subscription API response:"); log(LogLevel.DEBUG,"Subscription API response:");
//log(LogLevel.DEBUG,JSON.stringify(response,null,2)); log(LogLevel.DEBUG,JSON.stringify(response,null,2));
if (response.data.length === 0 || oldFirstUserid === response.data[0].user_id) break;
} else { } else {
break; break;
} }
@ -43,6 +45,7 @@ export async function processSubscriptions(streamer: twitch.StreamerUserIdTokenP
await dbconn.query("CALL update_scores()"); await dbconn.query("CALL update_scores()");
await dbconn.query('COMMIT'); await dbconn.query('COMMIT');
} catch (e) { } catch (e) {
log(LogLevel.ERROR,"cronjobs.processSubscriptions: Exception: " + e);
log(LogLevel.ERROR,"cronjobs.processSubscriptions: Exception thrown; rolling back"); log(LogLevel.ERROR,"cronjobs.processSubscriptions: Exception thrown; rolling back");
await dbconn.query('ROLLBACK'); await dbconn.query('ROLLBACK');
throw(e); throw(e);

View File

@ -52,36 +52,20 @@ export const updateVotePoints = {
text: "CALL update_vote_points($1,$2,$3)" text: "CALL update_vote_points($1,$2,$3)"
} }
// Request-related queries export const getRequestsTotal = {
export const getRequests = { name: "getRequestsTotal",
name: "getRequests", text: "SELECT COUNT(*) FROM requests_vw \
text: "SELECT * FROM requests_vw \ JOIN states ON requests_vw.state = states.state WHERE active"
JOIN states ON requests_vw.state = states.state \
WHERE active ORDER BY score DESC, reqTimestamp ASC LIMIT $1"
} }
export const getRequestsVoted = { export const getAllRequestsTotal = {
name: "getRequestsVoted", name: "getAllRequestsTotal",
text: "SELECT * FROM get_requests_voted($2) \ text: "SELECT COUNT(*) FROM requests_vw"
JOIN states ON get_requests_voted.state = states.state \
WHERE active ORDER BY score DESC, reqTimestamp ASC LIMIT $1"
}
export const getAllRequests = {
name: "getAllRequests",
text: "SELECT * FROM requests_vw \
ORDER BY score DESC, reqTimestamp ASC LIMIT $1"
}
export const getAllRequestsVoted = {
name: "getAllRequestsVoted",
text: "SELECT * FROM get_requests_voted($2) \
ORDER BY score DESC, reqTimestamp ASC LIMIT $1"
} }
export const checkRequestExists = { export const checkRequestExists = {
name: "checkRequestExists", name: "checkRequestExists",
text: "SELECT url FROM requests WHERE url = $1" text: "SELECT url,requester,state FROM requests_vw WHERE url = $1"
} }
export const addRequest = { export const addRequest = {
@ -146,3 +130,8 @@ export const updateCronJobLastSuccess = {
name: "updateCronJobLastSuccess", name: "updateCronJobLastSuccess",
text: "UPDATE cron SET lastSuccess = now() WHERE jobName = $1" text: "UPDATE cron SET lastSuccess = now() WHERE jobName = $1"
} }
export const getRateLimitStatus = {
name: "getRateLimitStatus",
text: "SELECT * FROM ratelimit_vw WHERE userid = $1"
}

View File

@ -4,30 +4,58 @@ import { log, LogLevel } from "./logging"
import pg from "pg"; import pg from "pg";
import db from "./db"; import db from "./db";
export async function getRequests(count: number) { export async function getRequests(count: number, offset: number, sort: string) {
var query = Object.assign(queries.getRequests, { values: [count] }); var query = {
text: "SELECT * FROM requests_vw \
JOIN states ON requests_vw.state = states.state WHERE active \
ORDER BY " + sort + " LIMIT $1 OFFSET $2",
values: [count,offset]
};
return db.query(query) return db.query(query)
.then((result: pg.QueryResult) => result.rows); .then((result: pg.QueryResult) => result.rows);
}; };
export async function getAllRequests(count: number) { export async function getRequestsVoted(count: number, offset: number, sort: string, user: number) {
var query = Object.assign(queries.getAllRequests, { values: [count] }); var query = {
text: "SELECT * FROM get_requests_voted($3) \
JOIN states ON get_requests_voted.state = states.state WHERE active \
ORDER BY " + sort + " LIMIT $1 OFFSET $2",
values: [count,offset,user]
};
return db.query(query) return db.query(query)
.then((result: pg.QueryResult) => result.rows); .then((result: pg.QueryResult) => result.rows);
}; };
export async function getRequestsVoted(count: number, user: number) { export async function getRequestsTotal() {
var query = Object.assign(queries.getRequestsVoted, { values: [count,user] }); return db.query(queries.getRequestsTotal)
.then((result: pg.QueryResult) => result.rows[0]["count"]);
};
export async function getAllRequests(count: number, offset: number, sort: string) {
var query = {
text: "SELECT * FROM requests_vw \
ORDER BY " + sort + " LIMIT $1 OFFSET $2",
values: [count,offset]
};
return db.query(query) return db.query(query)
.then((result: pg.QueryResult) => result.rows); .then((result: pg.QueryResult) => result.rows);
}; };
export async function getAllRequestsVoted(count: number,user: number) { export async function getAllRequestsVoted(count: number, offset: number, sort: string, user: number) {
var query = Object.assign(queries.getAllRequestsVoted, { values: [count,user] }); var query = {
text: "SELECT * FROM get_requests_voted($3) \
ORDER BY " + sort + " LIMIT $1 OFFSET $2",
values: [count,offset,user]
};
return db.query(query) return db.query(query)
.then((result: pg.QueryResult) => result.rows); .then((result: pg.QueryResult) => result.rows);
}; };
export async function getAllRequestsTotal() {
return db.query(queries.getAllRequestsTotal)
.then((result: pg.QueryResult) => result.rows[0]["count"]);
};
const validUrlRegexes = [ const validUrlRegexes = [
/^https:\/\/www\.youtube\.com\/watch\?v=[a-zA-Z0-9_-]{11}$/ /^https:\/\/www\.youtube\.com\/watch\?v=[a-zA-Z0-9_-]{11}$/
]; ];
@ -59,6 +87,7 @@ async function retrieveYoutubeMetadata(url: string) {
} }
export async function addRequest(url: string, requester: string): Promise<[number,string]> { export async function addRequest(url: string, requester: string): Promise<[number,string]> {
// Check that URL is of an accepted format
var validUrl = false; var validUrl = false;
for (var regex of validUrlRegexes) { for (var regex of validUrlRegexes) {
if (regex.test(url)) { if (regex.test(url)) {
@ -67,10 +96,24 @@ export async function addRequest(url: string, requester: string): Promise<[numbe
} }
} }
if (!validUrl) return [400, "Invalid song URL."]; if (!validUrl) return [400, "Invalid song URL."];
var result = await checkRequestExists(url)
if (result) { // Check whether the URL has already been requested
return [200,`Song already requested by ${result.rows[0].requester}. State: ${result.rows[0].state}`] var existsResult = await checkRequestExists(url)
if (existsResult) {
console.log(existsResult);
return [200,`Song already requested by ${existsResult.rows[0].requester}. State: ${existsResult.rows[0].state}`]
} }
// Check whether the user has hit their rate limit
var rateLimitQuery = Object.assign(queries.getRateLimitStatus, { values: [requester] });
var rateLimitResult = (await db.query(rateLimitQuery)).rows[0];
if (rateLimitResult.status) {
return [429,`You have reached your maximum of ${rateLimitResult.max} requests per day.
Please try again later.\n
Tip: Removing one of your requests from the past 24 hours by retracting your vote will allow you to replace it with another.`];
}
// Add the request
var query = Object.assign(queries.addRequest, { values: [url,requester] }); var query = Object.assign(queries.addRequest, { values: [url,requester] });
return db.query(query) return db.query(query)
.then(async () => { .then(async () => {

View File

@ -53,6 +53,7 @@ export async function apiRequest(tokens: TokenPair, endpoint: string): Promise <
return fetch("https://api.twitch.tv/helix" + endpoint, { headers: headers }) return fetch("https://api.twitch.tv/helix" + endpoint, { headers: headers })
.then(async (res: FetchResponse) => { .then(async (res: FetchResponse) => {
if (res.status == 200) { if (res.status == 200) {
log(LogLevel.DEBUG,"twitch.apiRequest: Request returned 200 for " + endpoint);
return res.json(); return res.json();
} else { } else {
log(LogLevel.WARNING,"twitch.apiRequest: Failed API request (pre-refresh):"); log(LogLevel.WARNING,"twitch.apiRequest: Failed API request (pre-refresh):");

View File

@ -5,8 +5,8 @@ import pg from "pg";
import db from "./db"; import db from "./db";
var versionMajor = 0; var versionMajor = 0;
var versionMinor = 5; var versionMinor = 8;
var versionPatch = 1; var versionPatch = 0;
export function getVersion() { export function getVersion() {
return `${versionMajor}.${versionMinor}.${versionPatch}` return `${versionMajor}.${versionMinor}.${versionPatch}`

View File

@ -29,16 +29,44 @@
<%- } %> <%- } %>
</div> </div>
<div id="main"> <div id="main">
<div class="tableSettings" id="tableSettingsTop">
<span style="width:420px">
Count:
<select id="count" value="10" onchange="window.count = this.value;updateTable()">
<option>5</option>
<option selected="selected">10</option>
<option>25</option>
<option>50</option>
<option>100</option>
</select>
</span>
<span>
Sort by:
<select id="sortBy" onchange="window.sortBy = this.value;updateTable()">
<option value="title">Song Title</option>
<option value="requester">Requester</option>
<option value="score">Score</option>
<option value="timestamp" selected>Request Time</option>
</select>
<button id="sortDir" onclick="toggleSortDir()">↓</button>
</span>
<span style="width:420px">
<input type="checkbox" id="allRequests" onchange="updateTable()">View all requests</input>
</span>
</div>
<div id="requests"></div><br> <div id="requests"></div><br>
Count: <div class="tableSettings">
<select id="count" value="10" onchange="updateTable()"> <span>
<option>5</option> <button id="pageBtnFirst" onclick="goToPage(1)">&lt;&lt;</button>
<option selected="selected">10</option> <button id="pageBtnPrev" onclick="goToPage(currentPage-1)">&lt;</button>
<option>25</option> <select id="page" onchange="goToPage(this.value)">
<option>50</option> <option value=1>1</option>
<option>100</option> </select>
</select> of <span id="totalPages">?</span>
<input type="checkbox" id="allRequests" onchange="updateTable()">View requests in any state</input> <button id="pageBtnNext" onclick="goToPage(currentPage+1)">&gt;</button>
<button id="pageBtnLast" onclick="goToPage(totalPages)">&gt;&gt;</button>
</span>
</div>
</div> </div>
<div id="modalBackground"> <div id="modalBackground">
<div class="modal" id="messageModal"> <div class="modal" id="messageModal">