Compare commits

...

46 Commits
v0.2 ... master

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
Dessa Simpson c634e763f3 Bump version to v0.5.1 2021-02-24 20:45:50 -07:00
Dessa Simpson 4607eaf307 Move ordering from views to queries.ts
Turns out views aren't guaranteed to keep their order ¯\_(ツ)_/¯

Fixes #24
2021-02-24 20:45:37 -07:00
Dessa Simpson 354eddb673 Bump version to v0.5 2021-02-24 20:15:16 -07:00
Dessa Simpson 6bc292f122 Fix regression where songs with 0 votes had $normaluservotepoints points
Also, remove now-unnecessary join with users
2021-02-24 20:04:40 -07:00
Dessa Simpson 2a87e0408e Retrieve follows/subscriptions from Twitch API 2021-02-24 19:40:58 -07:00
Dessa Simpson dbe7e4c52d Restructure DB in preparation for follower/subscriber implementation
- Remove user.is{follower,subscriber}, replacing with tables follows and 
subscriptions
- Update vote_score_vw to reflect new structure
- Add db upgrade script
2021-02-22 23:54:47 -07:00
Dessa Simpson 60defb7ea6 Allow streamer to set vote point values 2021-02-22 22:53:03 -07:00
Dessa Simpson fcd8f2b197 Bump version to 0.4.1 2021-02-22 21:11:07 -07:00
Dessa Simpson ad435e0dba Fix add request modal not appearing 2021-02-22 21:10:34 -07:00
Dessa Simpson d2cf4aa1d2 Bump version to 0.4 2021-02-21 23:52:49 -07:00
Dessa Simpson 6495e1c8ef Customization!
Add ability to control page title and color scheme.

Also, partially implements manual triggering of cronjobs.

Fixes #18
2021-02-21 23:25:48 -07:00
Dessa Simpson 1c34b3f013 Get page title and colors from database
See #18
2021-02-21 20:13:47 -07:00
Dessa Simpson 2e5762c029 Add release checklist (RELEASE.md) 2020-12-20 13:16:15 -07:00
Dessa Simpson 7a4c39353c Bump version in version.sql (forgot to in e83dbe51) 2020-12-20 10:43:01 -07:00
Dessa Simpson 57991dc36a Stringify twitchApiToken response for debug logging 2020-11-29 14:13:26 -07:00
Dessa Simpson 6d559b5fe2 Bump version to v0.3 2020-11-29 13:36:36 -07:00
Dessa Simpson f69e3a821c Add v0.2-v0.3 db upgrade script 2020-11-29 13:31:03 -07:00
Dessa Simpson 21650994d2 Implement checkVersion middleware
Checks version on each request, terminating the application if version 
mdoes not match
2020-11-29 13:30:27 -07:00
Dessa Simpson 35aa0914a2 Remove unnecessary import from twitch.ts 2020-11-29 13:29:10 -07:00
Dessa Simpson 2751ccf846 Update Dockerfile.prod and .dockerignore 2020-11-29 12:37:30 -07:00
Dessa Simpson aded3f4c77 Remove extraneous whitespace 2020-11-29 01:24:41 -07:00
Dessa Simpson af97647033 Make processBans support pagination 2020-11-29 01:17:58 -07:00
Dessa Simpson a86e8a5667 Implement cron
Fixes #22
2020-11-28 23:19:56 -07:00
Dessa Simpson 42d2a4a78c Add ISO date to logging 2020-11-28 22:09:06 -07:00
Dessa Simpson 88955ba707 Shorten container names in docker-compose.yml 2020-11-28 22:07:14 -07:00
Dessa Simpson 52f73745b6 Add prod dockerfile 2020-11-10 16:53:02 -07:00
Dessa Simpson a5a8d012c8 Bump version to 0.2.1 2020-11-10 15:10:08 -07:00
Dessa Simpson fd1d4613bd Make streamer name and profile picture in navbar link to twitch page 2020-11-10 15:09:27 -07:00
Dessa Simpson 51a4c5b2a3 Fix queries.getAllRequests calling requests_vw as a function
Fixes #23
2020-11-10 14:59:36 -07:00
40 changed files with 1290 additions and 206 deletions

View File

@ -1,4 +1,7 @@
Dockerfile
Dockerfile.prod
docker-compose.yml
.dockerignore
.git
.gitignore
node_modules

24
Dockerfile.prod Normal file
View File

@ -0,0 +1,24 @@
FROM node:14 AS builder
USER node
ENV PATH="/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:/app/node_modules/.bin"
COPY --chown=node . /app
WORKDIR /app
RUN ["npm", "install"]
RUN ["tsc", "--outDir", "build"]
FROM node:14-alpine
EXPOSE 3000
ENV PORT 3000
ENV PATH="/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:/app/node_modules/.bin" NODE_ENV="production"
USER node
WORKDIR /app
COPY --from=builder /app/build build
COPY --from=builder /app/node_modules node_modules
COPY --from=builder /app/public public
COPY --from=builder /app/views views
CMD ["node", "./build/app.js"]

30
RELEASE.md Normal file
View File

@ -0,0 +1,30 @@
# Release Checklist
## Release Type
- Were there any database schema changes?
- Are there any significant UI changes?
- Have any significant new features been added?
- Are there any API changes?
If the answer to any of the above is yes, then the release MUST be a major or minor release. Otherwise, the release MAY be a patch release (at developer's discretion).
## Major/Minor Releases
- [ ] 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
- At minimum, DB version must be bumped
- 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`
- [ ] Write release notes
## Patch Releases
- [ ] 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`
- [ ] Write release notes

View File

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

View File

@ -1,10 +1,14 @@
CREATE TABLE config (
rowlock bool DEFAULT TRUE UNIQUE NOT NULL CHECK (rowlock = TRUE),
normaluservotepoints int NOT NULL,
followervotepoints int NOT NULL,
subscribervotepoints int NOT NULL,
normaluservotepoints int NOT NULL DEFAULT 10,
followervotepoints int NOT NULL DEFAULT 50,
subscribervotepoints int NOT NULL DEFAULT 100,
normaluserratelimit int NOT NULL DEFAULT 1,
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)
);
INSERT INTO config (normalUserVotePoints,followerVotePoints,subscriberVotePoints)
VALUES (10,50,100);
INSERT INTO config (rowlock) VALUES (true);

13
db/10-cron.sql Normal file
View File

@ -0,0 +1,13 @@
CREATE TABLE cron (
jobName varchar UNIQUE NOT NULL, -- Application-recognizable name for the job
runinterval interval NOT NULL, -- Duration between runs
-- Last successful run - only gets updated if run is successful
lastSuccess timestamptz DEFAULT to_timestamp(0), -- Defaults to beginning of time
PRIMARY KEY(jobName)
);
INSERT INTO cron (jobName,runInterval) VALUES
('processBans','30 minutes'),
('processFollows','30 minutes'),
('processSubscriptions','30 minutes'),
('processEmptyMetadata','1 day');

View File

@ -2,7 +2,5 @@ CREATE TABLE users (
userId int NOT NULL,
displayName varchar NOT NULL,
imageUrl varchar,
isFollower boolean NOT NULL DEFAULT FALSE,
isSubscriber boolean NOT NULL DEFAULT FALSE,
PRIMARY KEY (userId)
);

View File

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

View File

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

View File

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

View File

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

4
db/50-follows.sql Normal file
View File

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

4
db/50-subscriptions.sql Normal file
View File

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

View File

@ -2,8 +2,7 @@ CREATE OR REPLACE VIEW requests_vw AS
SELECT url,COALESCE(videoTitle,url) AS title,displayName AS requester,imageUrl,state,score,reqTimestamp FROM requests
JOIN requestMetadata USING (url)
JOIN scores USING (url)
JOIN users ON requests.requester = users.userid
ORDER BY score DESC, reqTimestamp ASC;
JOIN users ON requests.requester = users.userid;
CREATE OR REPLACE FUNCTION get_requests_voted(votinguserid int)
RETURNS TABLE (
@ -21,8 +20,7 @@ CREATE OR REPLACE FUNCTION get_requests_voted(votinguserid int)
SELECT url,title,requester,imageUrl,state,score,reqTimestamp,
(CASE WHEN votes.userid IS NULL THEN FALSE ELSE TRUE END) AS voted
FROM requests_vw
LEFT JOIN votes ON (requests_vw.url = votes.requesturl AND votes.userid = votinguserid)
ORDER BY score DESC, reqTimestamp ASC;
LEFT JOIN votes ON (requests_vw.url = votes.requesturl AND votes.userid = votinguserid);
$$;
/*
@ -40,18 +38,20 @@ CREATE OR REPLACE VIEW vote_score_vw AS
COUNT(votes.requesturl) AS count,
COALESCE(
SUM(CASE
WHEN users.isfollower = FALSE AND users.issubscriber = FALSE
WHEN follows.userid IS NULL AND subscriptions.userid IS NULL
AND votes.userid IS NOT NULL
THEN votepoints.normaluservotepoints
WHEN users.isfollower = TRUE AND users.issubscriber = FALSE
WHEN follows.userid IS NOT NULL AND subscriptions.userid IS NULL
THEN votepoints.followervotepoints
WHEN users.issubscriber = TRUE
WHEN subscriptions.userid IS NOT NULL
THEN votepoints.subscribervotepoints
END), 0
) AS votescore
FROM requests
LEFT JOIN votes ON votes.requesturl = requests.url
LEFT JOIN users on votes.userid = users.userid
LEFT JOIN bans ON users.userid = bans.userid
LEFT JOIN bans ON votes.userid = bans.userid
LEFT JOIN follows ON votes.userid = follows.userid
LEFT JOIN subscriptions ON votes.userid = subscriptions.userid
CROSS JOIN votepoints
WHERE bans.userid IS NULL
GROUP BY url;
@ -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
LEFT JOIN users
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();
$$;
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)
LANGUAGE SQL
AS $$
@ -28,6 +35,7 @@ CREATE OR REPLACE PROCEDURE delete_vote(url varchar,voteuser int)
AS $$
DELETE FROM votes WHERE requesturl = url AND userid = voteuser;
CALL update_scores();
CALL clear_zero_votes();
$$;
CREATE OR REPLACE PROCEDURE update_request_score_modifier(updateurl varchar, scoreDiff int)
@ -36,3 +44,11 @@ CREATE OR REPLACE PROCEDURE update_request_score_modifier(updateurl varchar, sco
UPDATE scores SET scoreModifier = scoreModifier + scoreDiff WHERE url = updateurl;
CALL update_scores();
$$;
CREATE OR REPLACE PROCEDURE update_vote_points(normaluser int, follower int, subscriber int)
LANGUAGE SQL
AS $$
UPDATE config SET normaluservotepoints = normaluser,
followervotepoints = follower, subscribervotepoints = subscriber;
CALL update_scores();
$$;

View File

@ -1,8 +1,16 @@
INSERT INTO users (userid,displayName,isFollower,isSubscriber) VALUES
(001,'TestUser',false,false),
(002,'TestFollower',true,false),
(003,'TestSubscriber',true,true),
(004,'TestSubNonFollower',false,true);
INSERT INTO users (userid,displayName) VALUES
(001,'TestUser'),
(002,'TestFollower'),
(003,'TestSubscriber'),
(004,'TestSubNonFollower');
INSERT INTO follows (userid) VALUES
(002),
(003);
INSERT INTO subscriptions (userid) VALUES
(003),
(004);
CALL add_request('https://www.youtube.com/watch?v=dQw4w9WgXcQ',001);
CALL add_request('https://www.youtube.com/watch?v=C5oeWHngDS4',002);

18
db/upgrade/v0.2-v0.3.sql Normal file
View File

@ -0,0 +1,18 @@
BEGIN;
UPDATE version SET minor = 3;
CREATE TABLE cron (
jobName varchar UNIQUE NOT NULL, -- Application-recognizable name for the job
runinterval interval NOT NULL, -- Duration between runs
-- Last successful run - only gets updated if run is successful
lastSuccess timestamptz DEFAULT to_timestamp(0), -- Defaults to beginning of time
PRIMARY KEY(jobName)
);
INSERT INTO cron (jobName,runInterval) VALUES
('processBans','30 minutes'),
('processEmptyMetadata','1 day');
COMMIT;

9
db/upgrade/v0.3-0.4.sql Normal file
View File

@ -0,0 +1,9 @@
BEGIN;
UPDATE version SET minor = 4;
ALTER TABLE config ADD COLUMN title varchar, ADD COLUMN colors jsonb;
UPDATE config SET title = '{username}''s Learn Request Queue', colors = '{"bg": {"primary": "#444444","table": "#282828","navbar" : "#666666","error": "#ff0000"},"fg": {"primary": "#dddddd","ahover": "#ffffff","title": "#eeeeee"}}';
ALTER TABLE config ALTER COLUMN title SET NOT NULL, ALTER COLUMN colors SET NOT NULL;
COMMIT;

53
db/upgrade/v0.4-0.5.sql Normal file
View File

@ -0,0 +1,53 @@
BEGIN;
UPDATE version SET minor = 5;
CREATE OR REPLACE PROCEDURE update_vote_points(normaluser int, follower int, subscriber int)
LANGUAGE SQL
AS $$
UPDATE config SET normaluservotepoints = normaluser,
followervotepoints = follower, subscribervotepoints = subscriber;
CALL update_scores();
$$;
CREATE TABLE follows (
userid integer,
PRIMARY KEY (userid)
);
CREATE TABLE subscriptions (
userid integer,
PRIMARY KEY (userid)
);
CREATE OR REPLACE VIEW vote_score_vw AS
WITH votepoints AS (SELECT normaluservotepoints, followervotepoints, subscribervotepoints FROM config)
SELECT requests.url AS url,
COUNT(votes.requesturl) AS count,
COALESCE(
SUM(CASE
WHEN follows.userid IS NULL AND subscriptions.userid IS NULL
AND votes.userid IS NOT NULL
THEN votepoints.normaluservotepoints
WHEN follows.userid IS NOT NULL AND subscriptions.userid IS NULL
THEN votepoints.followervotepoints
WHEN subscriptions.userid IS NOT NULL
THEN votepoints.subscribervotepoints
END), 0
) AS votescore
FROM requests
LEFT JOIN votes ON votes.requesturl = requests.url
LEFT JOIN bans ON votes.userid = bans.userid
LEFT JOIN follows ON votes.userid = follows.userid
LEFT JOIN subscriptions ON votes.userid = subscriptions.userid
CROSS JOIN votepoints
WHERE bans.userid IS NULL
GROUP BY url;
ALTER TABLE users DROP COLUMN isfollower, DROP COLUMN issubscriber;
INSERT INTO cron (jobName,runInterval) VALUES
('processFollows','30 minutes'),
('processSubscriptions','30 minutes');
COMMIT;

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,7 +1,7 @@
version: '3.8'
services:
app:
container_name: learn-request-queue
container_name: lrq
build: .
depends_on:
- db
@ -11,7 +11,7 @@ services:
- "80:3000"
env_file: .env
db:
container_name: learn-request-queue-db
container_name: lrqdb
image: postgres
ports:
- "5432:5432"

View File

@ -1,28 +1,52 @@
var requestsDiv = document.getElementById("requests");
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) {
var reqUrl;
if (allRequests) {
reqUrl = "/api/getAllRequests";
} else {
reqUrl = "/api/getRequests";
}
reqUrl += `?count=${count}`;
function getRequests(offset,allRequests) {
if (allRequests) var reqUrl = "/api/getAllRequests";
else var reqUrl = "/api/getRequests";
reqUrl += `?count=${count}&offset=${offset}&sort=${sortBy}&sortDirection=${sortDir}`;
fetch(reqUrl)
.then(response => response.json())
.then(requests => {
window.requests = requests;
buildTable();
buildTable(requests);
});
}
function buildTable() {
var requestsDivHTML = '<table><tr><th class="request-link">Song</th><th class="request-requester">Requester</th><th class="request-score">Score</th>';
function buildTable(requests) {
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>';
if (window.loggedIn) requestsDivHTML += '<th class="request-vote">Vote</td>';
if (window.isStreamer) requestsDivHTML += '<th class="request-update">Update</th>'
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>\
<td class="request-requester">${request.imageurl ? `<img src="${request.imageurl}" class="table-userpic"/>` : ''}${request.requester}</td>\
<td class="request-score">${request.score}</td>`;
@ -44,12 +68,14 @@ function buildTable() {
}
function updateTable() {
allRequests = document.getElementById("allRequests").checked;
getRequests(document.getElementById("count").value,allRequests);
var offset = (currentPage - 1) * count;
var allRequests = document.getElementById("allRequests").checked;
getRequests(offset,allRequests);
}
function applyUrlTransforms(url) {
console.log("Begin applyUrlTransforms:" + url);
url = url.trim();
if (url.match(/^https?:\/\/(www\.)?youtu(\.be|be\.com)\//)) { // Youtube
console.log("Youtube");
var videoid = "";
@ -65,6 +91,27 @@ function applyUrlTransforms(url) {
}
}
function goToPage(page) {
currentPage = parseInt(page,10);
updateTable();
}
function getColorObject() {
return {
bg: {
primary: document.getElementById('color-bg-primary').value,
table: document.getElementById('color-bg-table').value,
navbar: document.getElementById('color-bg-navbar').value,
error: document.getElementById('color-bg-error').value,
},
fg: {
primary: document.getElementById('color-fg-primary').value,
ahover: document.getElementById('color-fg-ahover').value,
title: document.getElementById('color-fg-title').value,
}
}
}
function addRequestErr(msg) {
document.getElementById('addRequestError').style.display = "inline-block";
document.getElementById('addRequestError').innerText = msg;
@ -85,33 +132,46 @@ function updateRequestErrReset() {
document.getElementById('updateRequestError').innerText = "";
}
function showMessage(msg) {
document.getElementById("messageModalText").innerText = msg;
function streamerSettingsErr(msg) {
document.getElementById('streamerSettingsError').style.display = "inline-block";
document.getElementById('streamerSettingsError').innerText = msg;
}
function streamerSettingsErrReset() {
document.getElementById('streamerSettingsError').style.display = "none";
document.getElementById('streamerSettingsError').innerText = "";
}
// Hides all modals in preparation to show a one or to close out of
// all modals and return to the page. Does NOT hide modalBackground.
function hideModals() {
document.getElementById("messageModal").style.display = "none";
document.getElementById("addRequestModal").style.display = "none";
document.getElementById("updateRequestModal").style.display = "none";
document.getElementById("deleteRequestModal").style.display = "none";
document.getElementById("streamerSettingsModal").style.display = "none";
}
function closeAllModals() {
hideModals();
document.getElementById("modalBackground").style.display = "none";
}
function showMessage(msg) {
hideModals();
document.getElementById("messageModalText").innerText = msg;
document.getElementById("modalBackground").style.display = "flex";
document.getElementById("messageModal").style.display = "block";
}
function closeMessageModal() {
document.getElementById("modalBackground").style.display = "none";
document.getElementById("messageModal").style.display = "none";
}
function openAddRequestModal() {
hideModals();
document.getElementById("modalBackground").style.display = "flex";
document.getElementById("updateRequestModal").style.display = "none";
document.getElementById("messageModal").style.display = "none";
document.getElementById("addRequestModal").style.display = "block";
}
function closeAddRequestModal() {
document.getElementById("modalBackground").style.display = "none";
document.getElementById("addRequestModal").style.display = "none";
}
function openUpdateRequestModal(tr) {
hideModals();
var url = tr.getElementsByClassName('request-link')[0].firstChild.href;
var score = tr.getElementsByClassName('request-score')[0].innerText;
var state = tr.getElementsByClassName('request-state')[0].innerText;
@ -120,18 +180,12 @@ function openUpdateRequestModal(tr) {
document.getElementById("updateRequestModalCurrentScore").innerText = score;
document.querySelector(`#updateRequestStateSelect [value="${state}"]`).selected = true;
document.getElementById("scoreModifierInput").value = 0;
document.getElementById("messageModal").style.display = "none";
document.getElementById("addRequestModal").style.display = "none";
document.getElementById("modalBackground").style.display = "flex";
document.getElementById("updateRequestModal").style.display = "block";
}
function closeUpdateRequestModal() {
document.getElementById("modalBackground").style.display = "none";
document.getElementById("updateRequestModal").style.display = "none";
}
function openDeleteRequestModal(url) {
hideModals();
document.getElementById("updateRequestUrl").href = url;
document.getElementById("updateRequestUrl").innerText = url;
document.getElementById("messageModal").style.display = "none";
@ -141,17 +195,21 @@ function openDeleteRequestModal(url) {
document.getElementById("deleteRequestModal").style.display = "block";
}
// Returns to update request modal
function closeDeleteRequestModal() {
hideModals();
document.getElementById("deleteRequestModal").style.display = "none";
document.getElementById("updateRequestModal").style.display = "block";
}
function closeAllModals() {
document.getElementById("messageModal").style.display = "none";
document.getElementById("addRequestModal").style.display = "none";
document.getElementById("updateRequestModal").style.display = "none";
document.getElementById("deleteRequestModal").style.display = "none";
document.getElementById("modalBackground").style.display = "none";
function openStreamerSettingsModal() {
hideModals();
document.getElementById("modalBackground").style.display = "flex";
document.getElementById("streamerSettingsModal").style.display = "block";
}
function cronRequest(job) {
if (!cronJobs.includes(job)) throw new Error("Request for invalid job");
}
const validUrlRegexes = [
@ -178,7 +236,7 @@ function validateAndSubmitRequest() {
updateTable();
document.getElementById("addRequestUrl").value = "";
response.text().then((message) => {
closeAddRequestModal();
closeAllModals();
showMessage(message);
});
});
@ -262,14 +320,55 @@ function deleteRequest(url) {
});
}
function updatePageTitle(pageTitle) {
streamerSettingsErrReset();
fetch("/api/updatePageTitle", { method: 'POST', body: new URLSearchParams({
pageTitle: pageTitle
})})
.then(response => {
if (response.ok) {
location.reload();
} else {
response.text().then(streamerSettingsErr);
}
});
}
function updateColors(colors) {
streamerSettingsErrReset();
fetch("/api/updateColors", { method: 'POST', headers: {
'Content-Type': 'application/json'
}, body: JSON.stringify(colors)})
.then(response => {
if (response.ok) {
location.reload();
} else {
response.text().then(streamerSettingsErr);
}
});
}
function updateVotePoints(user,follower,subscriber) {
streamerSettingsErrReset();
fetch("/api/updateVotePoints", { method: 'POST', body: new URLSearchParams({
user: user,
follower: follower,
subscriber: subscriber
})})
.then(response => {
if (response.ok) {
location.reload();
} else {
response.text().then(streamerSettingsErr);
}
});
}
updateTable();
document.addEventListener("keydown", function onEvent(event) {
if (event.key === "Escape") {
closeMessageModal();
closeAddRequestModal();
closeAllModals();
}
});
document.getElementById("modalBackground").addEventListener("click", (e) => { if (e.target === e.currentTarget) closeAllModals();});
@ -279,5 +378,16 @@ for(state of validStates) {
var opt = document.createElement("option");
opt.text = 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

@ -12,6 +12,12 @@ button, input, select {
font-size: 100%;
}
input[type="color"] {
border: none;
padding: 0px;
vertical-align: middle;
}
a {
color: #ddd;
}
@ -105,6 +111,20 @@ div#nav-userpic {
text-align: right;
}
.tableSettings {
display: flex;
align-items: center;
justify-content: space-between;
}
.tableSettings > span {
margin: auto;
}
#tableSettingsTop {
margin-bottom: 10px;
}
#modalBackground {
display: none;
position: fixed;
@ -117,6 +137,7 @@ div#nav-userpic {
height: 100%;
background-color: #444;
background-color: #444a;
margin: 5px;
}
.modal {
@ -179,3 +200,18 @@ div#nav-userpic {
#deleteRequestLink {
color: #f00;
}
#streamerSettingsMain {
max-height: 65vh;
overflow-y: scroll;
}
#votepoints {
padding: 0 1.5em;
display: flex;
justify-content: space-between;
}
#sortDir {
padding: 0;
}

View File

@ -2,13 +2,14 @@ import * as config from "./config";
import * as requests from "./requests";
import * as twitch from "./twitch";
import * as queries from "./queries";
import { log, LogLevel } from "./logging"
import { URLSearchParams } from "url";
import express from "express";
import session from "express-session";
import pg from "pg";
import pgSessionStore from "connect-pg-simple";
import fetch, { Response as FetchResponse } from "node-fetch";
import { log, LogLevel } from "./logging"
import cron from "./cron";
import db from "./db";
import errorHandler from "./errors";
import * as version from "./version";
@ -26,8 +27,10 @@ async function validateApiToken(session: Express.Session) {
}
const app = express();
app.use(version.checkVersionMiddleware);
app.use(express.static('public'));
app.use(express.urlencoded({extended: false}));
app.use(express.json());
app.use(session({
secret: config.sessionSecret,
saveUninitialized: false,
@ -39,31 +42,85 @@ app.use(session({
// API
app.get("/api/getRequests", async (request, response) => {
if (!request.session) {
throw new Error ("Missing request.session")
}
var requestCount = ( request.query.count ? parseInt(request.query.count as string, 10) : 5 );
if (!request.session) throw new Error ("Missing 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) {
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));
} 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));
}
});
app.get("/api/getAllRequests", async (request, response) => {
if (!request.session) {
throw new Error ("Missing request.session")
}
var requestCount = ( request.query.count ? parseInt(request.query.count as string, 10) : 5 );
if (!request.session) throw new Error ("Missing 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) {
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));
} 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));
}
});
@ -76,7 +133,8 @@ app.post("/api/addRequest", async (request, response) => {
response.send("Session expired; please log in again");
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) {
response.status(401);
response.send("You are banned; you may not add new requests.");
@ -187,6 +245,139 @@ app.post("/api/updateRequestScoreModifier", async (request, response) => {
.catch((e: any) => errorHandler(request,response,e));
});
app.post("/api/updatePageTitle", async (request, response) => {
if (request.session) await validateApiToken(request.session);
if (!request.session || !request.session.user) {
response.status(401);
response.send("Session expired; please log in again");
return;
}
var streamerid = await db.query(queries.getStreamerId).then((result: pg.QueryResult) => result.rows[0]['userid']);
if (request.session.user.id != streamerid) {
response.status(401);
response.send("You are not the streamer");
return;
}
if (!request.body.pageTitle) {
response.status(400);
response.send("Missing pageTitle");
return;
}
var pageTitle = request.body.pageTitle as string;
response.type('text/plain');
pageTitle = pageTitle.replace(/[&<>"']/g, function(m) {
switch (m) {
case '&':
return '&amp;';
case '<':
return '&lt;';
case '>':
return '&gt;';
case '"':
return '&quot;';
case "'":
return '&apos;';
default:
return '';
}
});
await db.query(Object.assign(queries.updatePageTitle,{ values: [pageTitle] }))
.catch((e: any) => errorHandler(request,response,e));
response.status(200);
response.send('Successfully updated page title');
});
app.post("/api/updateColors", async (request, response) => {
if (request.session) await validateApiToken(request.session);
if (!request.session || !request.session.user) {
response.status(401);
response.send("Session expired; please log in again");
return;
}
var streamerid = await db.query(queries.getStreamerId).then((result: pg.QueryResult) => result.rows[0]['userid']);
if (request.session.user.id != streamerid) {
response.status(401);
response.send("You are not the streamer");
return;
}
if (!request.body.bg) {
response.status(400);
response.send("Missing bg");
return;
}
if (!request.body.fg) {
response.status(400);
response.send("Missing fg");
return;
}
type Colors = { [key: string]: { [key: string]: string} }
var colors: Colors = { bg: {}, fg: {} };
console.log(JSON.stringify(request.body,null,2));
for (var color of ['primary','table','navbar','error']) {
var setcolor = request.body.bg[color];
if (/^#[0-9a-fA-F]{6}$/.test(setcolor)) {
colors.bg[color] = setcolor
} else {
response.status(400);
response.send(`Color 'bg.${color}' missing or invalid`)
return;
}
}
for (var color of ['primary','ahover','title']) {
var setcolor = request.body.fg[color];
if (/^#[0-9a-fA-F]{6}$/.test(setcolor)) {
colors.fg[color] = setcolor
} else {
response.status(400);
response.send(`Color 'fg.${color}' missing or invalid`)
return;
}
}
response.type('text/plain');
await db.query(Object.assign(queries.updateColors,{ values: [JSON.stringify(colors)] }))
.catch((e: any) => errorHandler(request,response,e));
response.status(200);
response.send('Successfully updated colors');
});
app.post("/api/updateVotePoints", async (request, response) => {
if (request.session) await validateApiToken(request.session);
if (!request.session || !request.session.user) {
response.status(401);
response.send("Session expired; please log in again");
return;
}
var streamerid = await db.query(queries.getStreamerId).then((result: pg.QueryResult) => result.rows[0]['userid']);
if (request.session.user.id != streamerid) {
response.status(401);
response.send("You are not the streamer");
return;
}
if (!request.body.user) {
response.status(400);
response.send("Missing user");
return;
}
if (!request.body.follower) {
response.status(400);
response.send("Missing follower");
return;
}
if (!request.body.subscriber) {
response.status(400);
response.send("Missing subscriber");
return;
}
var user = request.body.user as number;
var follower = request.body.follower as number;
var subscriber = request.body.subscriber as number;
response.type('text/plain');
await db.query(Object.assign(queries.updateVotePoints,{ values: [user,follower,subscriber] }))
.catch((e: any) => errorHandler(request,response,e));
response.status(200);
response.send('Successfully updated page title');
});
app.post("/api/deleteRequest", async (request, response) => {
if (request.session) await validateApiToken(request.session);
if (!request.session || !request.session.user) {
@ -264,6 +455,37 @@ app.post("/api/deleteVote", async (request,response) => {
.catch((e: any) => errorHandler(request,response,e));
});
app.get("/api/cronRequest", async (request, response) => {
if (request.session) await validateApiToken(request.session);
if (!request.session || !request.session.user) {
response.status(401);
response.send("Session expired; please log in again");
return;
}
var streamerid = await db.query(queries.getStreamerId).then((result: pg.QueryResult) => result.rows[0]['userid']);
if (request.session.user.id != streamerid) {
response.status(401);
response.send("You are not the streamer");
return;
}
if (!request.query.job) {
response.status(400);
response.send("Missing job");
return;
}
var job = request.body.job as string;
try {
cron.validateJob(job)
} catch (e) {
response.status(400);
response.send("Invalid job")
return;
}
response.type('text/plain');
cron.request(job).catch((e: any) => errorHandler(request,response,e));
});
// Twitch callback
app.get("/callback", async (request, response) => {
if (request.query.error) {
@ -300,7 +522,7 @@ app.get("/callback", async (request, response) => {
app.get("/", async (request, response) => {
if (request.session) await validateApiToken(request.session);
var streamerInfo = await db.query(queries.getStreamerInfo).then((result: pg.QueryResult) => result.rows[0]);
var validStates = JSON.stringify((await db.query(queries.getValidStates).then((result: pg.QueryResult) => result.rows)).map((row: any) => row.state));
var streamerConfig = await db.query(queries.getConfig).then((result: pg.QueryResult) => result.rows[0]);
if (typeof streamerInfo == 'undefined') {
response.redirect(307, `https://id.twitch.tv/oauth2/authorize?client_id=${config.twitchClientId}&redirect_uri=${config.urlPrefix}/callback&response_type=code&scope=channel:read:subscriptions moderation:read`);
return;
@ -311,9 +533,11 @@ app.get("/", async (request, response) => {
clientId: config.twitchClientId,
urlPrefix: config.urlPrefix,
streamerName: streamerInfo['displayname'],
streamerProfilePicture: streamerInfo['imageurl']
streamerProfilePicture: streamerInfo['imageurl'],
config: streamerConfig,
});
} else {
var validStates = JSON.stringify((await db.query(queries.getValidStates).then((result: pg.QueryResult) => result.rows)).map((row: any) => row.state));
response.render('main.eta', {
loggedIn: true,
userName: request.session.user.display_name,
@ -321,72 +545,36 @@ app.get("/", async (request, response) => {
validStates: validStates,
isStreamer: streamerInfo['userid'] == request.session.user.id,
streamerName: streamerInfo['displayname'],
streamerProfilePicture: streamerInfo['imageurl']
streamerProfilePicture: streamerInfo['imageurl'],
config: streamerConfig,
});
}
});
app.get("/colors.css", async (_request, response) => {
var streamerInfo = await db.query(queries.getStreamerInfo).then((result: pg.QueryResult) => result.rows[0]);
var colors = await db.query(queries.getConfig).then((result: pg.QueryResult) => result.rows[0]['colors']);
if (typeof streamerInfo == 'undefined') return;
response.contentType("text/css");
response.render('colors.eta', colors);
});
// Streamer Panel
//app.get("/streamer/", async (request, response) => {
//
//});
// Logout
app.get("/logout", async (request, response) => request.session!.destroy(() => response.redirect(307, '/')));
async function processBannedUsers() {
log(LogLevel.INFO,"processBannedUsers run at " + new Date().toISOString());
var streamer = await db.query(queries.getStreamerIdToken).then((result: pg.QueryResult) => result.rows[0]);
if (typeof streamer == 'undefined') return;
var response = await twitch.streamerApiRequest("/moderation/banned?broadcaster_id=" + streamer['userid']);
var dbconn = await db.connect();
try {
await dbconn.query('BEGIN');
dbconn.query("DELETE FROM bans");
log(LogLevel.DEBUG,"Ban list:")
log(LogLevel.DEBUG,JSON.stringify(response.data,null,2));
var insertBanQuery = "INSERT INTO bans (userid) VALUES ";
var banRow = 0;
var bansArray: number[] = [];
for (var ban of response.data) {
if (ban.expires_at == '') {
banRow++;
insertBanQuery += `($${banRow}), `;
bansArray.push(ban.user_id as number);
}
}
var banQueryConfig = {
text: insertBanQuery.slice(0,-2), // Cut last `, ` off of the end
values: bansArray
};
log(LogLevel.DEBUG,"banQueryConfig object:")
log(LogLevel.DEBUG,JSON.stringify(banQueryConfig,null,2))
dbconn.query(banQueryConfig);
dbconn.query("CALL update_scores()");
await dbconn.query('COMMIT');
} catch (e) {
await dbconn.query('ROLLBACK');
} finally {
dbconn.release();
}
setTimeout(processBannedUsers,3600000+Math.floor(Math.random()*900000)) // Run every 1-1.25 hours to balance load
}
setTimeout(processBannedUsers,600000+Math.floor(Math.random()*600000))
async function processEmptyMetadata() {
log(LogLevel.INFO,"processEmptyMetadata run at " + new Date().toISOString());
var result = await db.query(queries.getRequestsWithEmptyMetadata);
for (var row of result.rows) {
log(LogLevel.DEBUG,"Processing empty metadata for request: " + row['url']);
requests.updateRequestMetadata(row['url']);
}
setTimeout(processEmptyMetadata,3600000+Math.floor(Math.random()*900000)) // Run every 1-1.25 hours to balance load
}
processEmptyMetadata();
app.get("/logout", async (request, response) =>
request.session!.destroy(() => response.redirect(307, '/')));
// Check version then listen
version.checkVersion().then(_ => app.listen(config.port, () => {
cron.run();
setInterval(cron.run,config.cronInterval);
console.log(`Listening on port ${config.port}`);
}));
}))
.catch((e) => {
log(LogLevel.ERROR,e)
process.exit(1);
});

View File

@ -36,6 +36,13 @@ if (typeof process.env.YOUTUBE_SECRET == 'undefined') {
}
export const youtubeSecret: string = process.env.YOUTUBE_SECRET!;
export const logLevel = (process.env.LOG_LEVEL ? LogLevel[process.env.LOG_LEVEL as keyof typeof LogLevel] : LogLevel.ERROR );
export const logLevel = (process.env.LOG_LEVEL
? LogLevel[process.env.LOG_LEVEL as keyof typeof LogLevel]
: LogLevel.ERROR );
export const cronInterval: number = (process.env.CRON_INTERVAL
? parseInt(process.env.CRON_INTERVAL as string, 10)
: 60000);
console.log("Running with logLevel = " + logLevel)

70
src/cron.ts Normal file
View File

@ -0,0 +1,70 @@
import cronjobs from "./cronjobs"
import * as queries from "./queries";
import * as twitch from "./twitch";
import { log, LogLevel } from "./logging"
import db from "./db";
import pg from "pg";
interface CronJob {
(streamer: twitch.StreamerUserIdTokenPair): Promise<void>
}
function validateJob(job: string) {
if (!Object.keys(cronjobs).includes(job)) throw new Error("Invalid cronjob " + job);
}
async function runJob(job: string) {
validateJob(job);
var streamer = await db.query(queries.getStreamerIdToken).then(
(result: pg.QueryResult) => result.rows[0]);
return await (cronjobs as { [key: string]: CronJob })[job](streamer);
}
// Run a specific job on request of streamer
async function request(job: string) {
validateJob(job);
// TODO: Rate limiting
await runJob(job);
}
async function run() {
// If instance is not yet set up, end processing at this point
var streamer = await db.query(queries.getStreamerIdToken).then(
(result: pg.QueryResult) => result.rows[0]);
if (typeof streamer == 'undefined') {
log(LogLevel.INFO,"Cron run skipped due to instance not being set up")
return;
}
log(LogLevel.INFO,"Begin cron run")
for (var job in cronjobs) {
log(LogLevel.INFO,"cron: Checking job " + job);
var dbconn = await db.connect();
await dbconn.query("BEGIN");
try {
var needsRun = (await dbconn.query(
Object.assign(queries.getAndLockCronJob,{values: [job]})
)).rowCount;
if (needsRun) {
log(LogLevel.INFO,`cron: Database says job ${job} needs run; executing`);
await (cronjobs as { [key: string]: CronJob })[job](streamer)
log(LogLevel.INFO,`cron: Job ${job} returned without exception; updating lastSuccess`);
await dbconn.query(Object.assign(queries.updateCronJobLastSuccess,{values: [job]}))
await dbconn.query("COMMIT");
} else {
log(LogLevel.INFO,`cron: Database says job ${job} does not need run; skipping`);
await dbconn.query("ROLLBACK");
}
} catch(e) {
log(LogLevel.ERROR,`cron: Job ${job} threw exception; rolling back`);
await dbconn.query("ROLLBACK");
log(LogLevel.ERROR,`cron: Job ${job} exception message: ${e}`)
} finally {
log(LogLevel.DEBUG,`cron: Job ${job} hit finally; releasing dbconn`);
dbconn.release();
}
log(LogLevel.INFO,"cron: Finished job " + job);
}
log(LogLevel.INFO,"End cron run")
}
export = {run,validateJob,request}

11
src/cronjobs/index.ts Normal file
View File

@ -0,0 +1,11 @@
import { processBans } from './processBans';
import { processFollows } from './processFollows';
import { processSubscriptions } from './processSubscriptions';
import { processEmptyMetadata } from './processEmptyMetadata';
export = {
processBans,
processFollows,
processSubscriptions,
processEmptyMetadata
}

View File

@ -0,0 +1,58 @@
import * as twitch from "../twitch";
import { log, LogLevel } from "../logging"
import db from "../db";
export async function processBans(streamer: twitch.StreamerUserIdTokenPair) {
var dbconn = await db.connect();
try {
await dbconn.query('BEGIN');
await dbconn.query("DELETE FROM bans");
var response = await twitch.streamerApiRequest(streamer,
`/moderation/banned?broadcaster_id=${streamer.userid}&first=100`);
log(LogLevel.DEBUG,"Ban API response:")
log(LogLevel.DEBUG,JSON.stringify(response,null,2));
while (true) {
var insertBanQuery = "INSERT INTO bans (userid) VALUES ";
var banRow = 0; // Used for $1, $2, etc. in parameterized query
var bansArray: number[] = [];
if (Object.keys(response.data).length > 0) {
for (var ban of response.data) {
if (ban.expires_at == '') {
banRow++;
insertBanQuery += `($${banRow}), `;
bansArray.push(ban.user_id as number);
}
}
insertBanQuery = insertBanQuery.slice(0,-2); // Cut last `, ` off of the end
insertBanQuery += " ON CONFLICT DO NOTHING"; // Deal with broken endpoint returning dupes
var banQueryConfig = {
text: insertBanQuery,
values: bansArray
};
log(LogLevel.DEBUG,"banQueryConfig object:")
log(LogLevel.DEBUG,JSON.stringify(banQueryConfig,null,2))
await dbconn.query(banQueryConfig);
}
if (response.pagination.cursor) {
var oldFirstUserid = response.data[0].user_id;
response = await twitch.streamerApiRequest(streamer,
`/moderation/banned?broadcaster_id=${streamer.userid}&after=${response.pagination.cursor}&first=100`);
log(LogLevel.DEBUG,"Ban API response:");
log(LogLevel.DEBUG,JSON.stringify(response,null,2));
// Work around broken api endpoint giving a cursor referring to the
// current page, causing an infinite loop
if (oldFirstUserid == response.data[0].user_id) break;
} else {
break;
}
}
await dbconn.query("CALL update_scores()");
await dbconn.query('COMMIT');
} catch (e) {
log(LogLevel.ERROR,"cronjobs.processBans: Exception thrown; rolling back");
await dbconn.query('ROLLBACK');
throw(e);
} finally {
await dbconn.release();
}
}

View File

@ -0,0 +1,12 @@
import * as requests from "../requests";
import * as queries from "../queries";
import { log, LogLevel } from "../logging"
import db from "../db";
export async function processEmptyMetadata(_: any) { // TODO: Consume streamer userid for WHERE clause
var result = await db.query(queries.getRequestsWithEmptyMetadata);
for (var row of result.rows) {
log(LogLevel.DEBUG,"Processing empty metadata for request: " + row['url']);
requests.updateRequestMetadata(row['url']);
}
}

View File

@ -0,0 +1,53 @@
import * as twitch from "../twitch";
import { log, LogLevel } from "../logging"
import db from "../db";
export async function processFollows(streamer: twitch.StreamerUserIdTokenPair) {
var dbconn = await db.connect();
try {
await dbconn.query('BEGIN');
await dbconn.query("DELETE FROM follows");
// Insert the streamer as a follower
await dbconn.query({text: "INSERT INTO follows (userid) VALUES ($1)", values: [streamer.userid]})
var response = await twitch.streamerApiRequest(streamer,
`/users/follows?to_id=${streamer.userid}&first=100`);
log(LogLevel.DEBUG,"Follows API response:")
log(LogLevel.DEBUG,JSON.stringify(response,null,2));
while (true) {
var insertFollowQuery = "INSERT INTO follows (userid) VALUES ";
var followRow = 0; // Used for $1, $2, etc. in parameterized query
var followsArray: number[] = [];
if (Object.keys(response.data).length > 0) {
for (var follow of response.data) {
followRow++;
insertFollowQuery += `($${followRow}), `;
followsArray.push(follow.from_id as number);
}
insertFollowQuery = insertFollowQuery.slice(0,-2); // Cut last `, ` off of the end
var followQueryConfig = {
text: insertFollowQuery,
values: followsArray
};
log(LogLevel.DEBUG,"followQueryConfig object:")
log(LogLevel.DEBUG,JSON.stringify(followQueryConfig,null,2))
await dbconn.query(followQueryConfig);
}
if (response.pagination.cursor) {
response = await twitch.streamerApiRequest(streamer,
`/users/follows?to_id=${streamer.userid}&after=${response.pagination.cursor}&first=100`);
log(LogLevel.DEBUG,"Follow API response:");
log(LogLevel.DEBUG,JSON.stringify(response,null,2));
} else {
break;
}
}
await dbconn.query("CALL update_scores()");
await dbconn.query('COMMIT');
} catch (e) {
log(LogLevel.ERROR,"cronjobs.processFollows: Exception thrown; rolling back");
await dbconn.query('ROLLBACK');
throw(e);
} finally {
await dbconn.release();
}
}

View File

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

View File

@ -10,5 +10,5 @@ export enum LogLevel {
export async function log(logLevel: LogLevel, logMessage: any) {
if (config.logLevel >= logLevel)
console.log(LogLevel[logLevel].padStart(7) + ' | ' + logMessage)
console.log(new Date().toISOString() + LogLevel[logLevel].padStart(7) + ' | ' + logMessage)
}

View File

@ -26,40 +26,46 @@ export const getStreamerInfo = {
text: "SELECT userid,displayname,imageurl FROM streamer_user_vw"
}
export const getConfig = {
name: "getConfig",
text: "SELECT * FROM config"
}
export const updateStreamer = {
name: "updateStreamer",
text: "INSERT INTO streamer (userid,tokenPair) VALUES ($1,$2)\
ON CONFLICT (userid) DO UPDATE SET tokenPair = $2"
}
// Request-related queries
export const getRequests = {
name: "getRequests",
text: "SELECT * FROM requests_vw \
JOIN states ON requests_vw.state = states.state \
WHERE active LIMIT $1"
export const updatePageTitle = {
name: "updatePageTitle",
text: "UPDATE config SET title = $1"
}
export const getRequestsVoted = {
name: "getRequestsVoted",
text: "SELECT * FROM get_requests_voted($2) \
JOIN states ON get_requests_voted.state = states.state \
WHERE active LIMIT $1"
export const updateColors = {
name: "updateColors",
text: "UPDATE config SET colors = $1"
}
export const getAllRequests = {
name: "getAllRequests",
text: "SELECT * FROM requests_vw() LIMIT $1"
export const updateVotePoints = {
name: "updateVotePoints",
text: "CALL update_vote_points($1,$2,$3)"
}
export const getAllRequestsVoted = {
name: "getAllRequestsVoted",
text: "SELECT * FROM get_requests_voted($2) LIMIT $1"
export const getRequestsTotal = {
name: "getRequestsTotal",
text: "SELECT COUNT(*) FROM requests_vw \
JOIN states ON requests_vw.state = states.state WHERE active"
}
export const getAllRequestsTotal = {
name: "getAllRequestsTotal",
text: "SELECT COUNT(*) FROM requests_vw"
}
export const 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 = {
@ -111,3 +117,21 @@ export const getDbVersion = {
name: "getDbVersion",
text: "SELECT get_version()"
}
export const getAndLockCronJob = {
name: "getCronJob",
text: `SELECT * FROM cron
WHERE (lastSuccess + runInterval) < now()
AND jobName = $1
FOR UPDATE SKIP LOCKED`
}
export const updateCronJobLastSuccess = {
name: "updateCronJobLastSuccess",
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 db from "./db";
export async function getRequests(count: number) {
var query = Object.assign(queries.getRequests, { values: [count] });
export async function getRequests(count: number, offset: number, sort: string) {
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)
.then((result: pg.QueryResult) => result.rows);
};
export async function getAllRequests(count: number) {
var query = Object.assign(queries.getAllRequests, { values: [count] });
export async function getRequestsVoted(count: number, offset: number, sort: string, user: number) {
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)
.then((result: pg.QueryResult) => result.rows);
};
export async function getRequestsVoted(count: number, user: number) {
var query = Object.assign(queries.getRequestsVoted, { values: [count,user] });
export async function getRequestsTotal() {
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)
.then((result: pg.QueryResult) => result.rows);
};
export async function getAllRequestsVoted(count: number,user: number) {
var query = Object.assign(queries.getAllRequestsVoted, { values: [count,user] });
export async function getAllRequestsVoted(count: number, offset: number, sort: string, user: number) {
var query = {
text: "SELECT * FROM get_requests_voted($3) \
ORDER BY " + sort + " LIMIT $1 OFFSET $2",
values: [count,offset,user]
};
return db.query(query)
.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 = [
/^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]> {
// Check that URL is of an accepted format
var validUrl = false;
for (var regex of validUrlRegexes) {
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."];
var result = await checkRequestExists(url)
if (result) {
return [200,`Song already requested by ${result.rows[0].requester}. State: ${result.rows[0].state}`]
// Check whether the URL has already been requested
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] });
return db.query(query)
.then(async () => {

View File

@ -2,7 +2,6 @@ import * as config from "./config";
import * as queries from "./queries";
import { log, LogLevel } from "./logging"
import fetch, { Response as FetchResponse } from "node-fetch";
import pg from "pg";
import db from "./db";
export interface TokenPair {
@ -10,6 +9,11 @@ export interface TokenPair {
refresh_token: string;
}
export interface StreamerUserIdTokenPair {
userid: number
tokenpair: TokenPair
}
// Refresh the API token. Returns true on success and false on failure.
async function refreshApiToken(tokens: TokenPair): Promise<boolean> {
log(LogLevel.DEBUG,`Call: twitch.refreshApiToken(${JSON.stringify(tokens,null,2)})`);
@ -25,8 +29,8 @@ async function refreshApiToken(tokens: TokenPair): Promise<boolean> {
if (res.status == 200) {
log(LogLevel.INFO,"twitch.refreshApiToken: Refresh returned success.");
var data = await (res.json() as Promise<TokenPair>);
log(LogLevel.DEBUG, "Returned data:")
log(LogLevel.DEBUG, data)
log(LogLevel.DEBUG, "Returned data:");
log(LogLevel.DEBUG, JSON.stringify(data,null,2));
tokens.access_token = data.access_token;
tokens.refresh_token = data.refresh_token;
return true;
@ -49,6 +53,7 @@ export async function apiRequest(tokens: TokenPair, endpoint: string): Promise <
return fetch("https://api.twitch.tv/helix" + endpoint, { headers: headers })
.then(async (res: FetchResponse) => {
if (res.status == 200) {
log(LogLevel.DEBUG,"twitch.apiRequest: Request returned 200 for " + endpoint);
return res.json();
} else {
log(LogLevel.WARNING,"twitch.apiRequest: Failed API request (pre-refresh):");
@ -85,15 +90,15 @@ export async function apiRequest(tokens: TokenPair, endpoint: string): Promise <
})
}
export async function streamerApiRequest(endpoint: string) {
export async function streamerApiRequest(streamer: StreamerUserIdTokenPair, endpoint: string) {
log(LogLevel.DEBUG,`Call: twitch.streamerApiRequest(${endpoint})`);
var streamer = await db.query(queries.getStreamerIdToken).then((result: pg.QueryResult) => result.rows[0]);
var tokenpair = streamer.tokenpair;
var originaltoken = tokenpair.access_token;
log(LogLevel.DEBUG,"Original token: " + tokenpair.access_token);
var response = await apiRequest(tokenpair,endpoint);
log(LogLevel.DEBUG,"New token: " + tokenpair.access_token);
if (tokenpair.access_token != originaltoken) await db.query(Object.assign(queries.updateStreamer,{ values: [streamer.userid,tokenpair] }))
var originaltoken = streamer.tokenpair.access_token;
log(LogLevel.DEBUG,"Original token: " + originaltoken);
var response = await apiRequest(streamer.tokenpair,endpoint);
log(LogLevel.DEBUG,"New token: " + streamer.tokenpair.access_token);
if (streamer.tokenpair.access_token != originaltoken)
await db.query(Object.assign(queries.updateStreamer,
{ values: [streamer.userid,streamer.tokenpair] }))
return response;
}

View File

@ -1,15 +1,30 @@
import * as queries from "./queries";
import { log, LogLevel } from "./logging"
import express from "express";
import pg from "pg";
import db from "./db";
var versionMajor = 0;
var versionMinor = 2;
var versionMinor = 8;
var versionPatch = 0;
export function getVersion() {
return `${versionMajor}.${versionMinor}.${versionPatch}`
}
export async function checkVersionMiddleware(_req: express.Request, _res: express.Response, next: express.NextFunction) {
try {
await checkVersion();
} catch (e) {
log(LogLevel.ERROR,e)
// Terminate the nodejs process with error. If restarted, the app will
// never start listening for new requests so this will not result in a
// restart loop.
process.exit(1);
}
next();
}
export async function checkVersion() {
var dbver = await db.query(queries.getDbVersion).then((result: pg.QueryResult) => result.rows[0]['get_version']);
if (dbver != `${versionMajor}.${versionMinor}`) {

41
views/colors.eta Normal file
View File

@ -0,0 +1,41 @@
body {
background-color: <%= it.bg.primary %>;
color: <%= it.fg.primary %>;
}
a {
color: <%= it.fg.primary %>;
}
a:hover {
color: <%= it.fg.ahover %>;
}
.error {
background-color: <%= it.bg.error %>;
}
#navbar {
background-color: <%= it.bg.navbar %>;
}
#nav-title, #nav-title a {
color: <%= it.fg.title %>;
}
#main {
background-color: <%= it.bg.table %>;
}
#modalBackground {
background-color: <%= it.bg.primary %>;
background-color: <%= it.bg.primary %>aa;
}
.modal {
background-color: <%= it.bg.primary %>;
}
#deleteRequestLink {
color: <%= it.bg.error %>;
}

View File

@ -1,8 +1,9 @@
<!DOCTYPE html>
<html>
<head>
<link rel=stylesheet href=style.css />
<title><%= it.streamerName %>'s Learn Request Queue</title>
<link rel=stylesheet href=/style.css />
<link rel=stylesheet href=/colors.css />
<title><%~ it.config.title.replace('{username}',it.streamerName) %></title>
<script>
window.loggedIn = <%= it.loggedIn %>;
window.validStates = <%~ it.validStates %>;
@ -12,11 +13,14 @@
</head>
<body>
<div id="navbar">
<div id="nav-streamerpic"><img src="<%= it.streamerProfilePicture %>" /></div>
<div id="nav-title"><%= it.streamerName %>'s Learn Request Queue</div>
<div id="nav-streamerpic"><a href="https://twitch.tv/<%= it.streamerName %>"><img src="<%= it.streamerProfilePicture %>" /></a></div>
<div id="nav-title"><%~ it.config.title.replace('{username}',`<a href="https://twitch.tv/${it.streamerName}">${it.streamerName}</a>`) %></div>
<div id="nav-requests"><a href="/">Requests</a></div>
<%- if (it.loggedIn) { -%>
<div id="nav-addrequest"><a href="#" onclick="openAddRequestModal()">Add Request</a></div>
<%- if (it.isStreamer) { -%>
<div id="nav-streamersettings"><a href="#" onclick="openStreamerSettingsModal()">Streamer Settings</a></div>
<%- } %>
<div id="nav-userpic"><img src="<%= it.userProfilePicture %>" /></div>
<div id="nav-username"><%= it.userName %></div>
<div id="nav-logout"><a href="/logout">Logout</a></div>
@ -25,24 +29,52 @@
<%- } %>
</div>
<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>
Count:
<select id="count" value="10" onchange="updateTable()">
<option>5</option>
<option selected="selected">10</option>
<option>25</option>
<option>50</option>
<option>100</option>
</select>
<input type="checkbox" id="allRequests" onchange="updateTable()">View requests in any state</input>
<div class="tableSettings">
<span>
<button id="pageBtnFirst" onclick="goToPage(1)">&lt;&lt;</button>
<button id="pageBtnPrev" onclick="goToPage(currentPage-1)">&lt;</button>
<select id="page" onchange="goToPage(this.value)">
<option value=1>1</option>
</select>
of <span id="totalPages">?</span>
<button id="pageBtnNext" onclick="goToPage(currentPage+1)">&gt;</button>
<button id="pageBtnLast" onclick="goToPage(totalPages)">&gt;&gt;</button>
</span>
</div>
</div>
<div id="modalBackground">
<div class="modal" id="messageModal">
<div class="modalClose"><a href="#" onclick="closeMessageModal()">&times;</a></div>
<div class="modalClose"><a href="#" onclick="closeAllModals()">&times;</a></div>
<span id="messageModalText"></span>
</div>
<div class="modal" id="addRequestModal">
<div class="modalClose"><a href="#" onclick="closeAddRequestModal()">&times;</a></div>
<div class="modalClose"><a href="#" onclick="closeAllModals()">&times;</a></div>
<h1>Add Request</h1>
<div class="error" id="addRequestError"></div>
<span id="addRequestInputContainer">
@ -52,7 +84,7 @@
Currently, only Youtube links are accepted.
</div>
<div class="modal" id="updateRequestModal">
<div class="modalClose"><a href="#" onclick="closeAddRequestModal()">&times;</a></div>
<div class="modalClose"><a href="#" onclick="closeAllModals()">&times;</a></div>
<h2>Update Request</h2>
<div class="error" id="updateRequestError"></div>
<br>
@ -83,7 +115,7 @@
</div>
<br>
<div>
<a id="updateMetadataLink" href="#" onclick="updateRequestMetadata(
<a href="#" onclick="updateRequestMetadata(
document.getElementById('updateRequestUrl').innerText
)">Update Request Metadata</a>
<br>
@ -111,6 +143,63 @@
<button onclick="closeDeleteRequestModal()">No</button>
<button onclick="deleteRequest(document.getElementById('updateRequestUrl').innerText)">Yes</button>
</div>
<div class="modal" id="streamerSettingsModal">
<div class="modalClose"><a href="#" onclick="closeAllModals()">&times;</a></div>
<h2>Streamer Settings</h2>
<div class="error" id="streamerSettingsError"></div>
<br>
<div id="streamerSettingsMain">
<div>
<h3 style='margin-bottom: 0.5em'>Vote Point Values</h3>
<p>
<div id="votepoints">
<div style="display: inline-block">User: <input type="number" id="userVotePoints" style="width: 3em" value="<%~ it.config.normaluservotepoints %>"></input></div>
<div style="display: inline-block">Follower: <input type="number" id="followerVotePoints" style="width: 3em" value="<%~ it.config.followervotepoints %>"></input></div>
<div style="display: inline-block">Subscriber: <input type="number" id="subscriberVotePoints" style="width: 3em" value="<%~ it.config.subscribervotepoints %>"></input></div>
</div>
<br>
<button onclick="updateVotePoints(
document.getElementById('userVotePoints').value,
document.getElementById('followerVotePoints').value,
document.getElementById('subscriberVotePoints').value
)">Submit</button>
<button onclick="updateVotePoints(10,50,100)">Reset</button>
</p>
</div>
<hr>
<div>
<h3 style='margin-bottom: 0.5em'>Customization</h3>
<p>
Page Title:
<input type="text" id="pageTitle" style="width: 15em" value="<%~ it.config.title %>"></input>
<button onclick="updatePageTitle(document.getElementById('pageTitle').value)">Submit</button>
<button onclick="updatePageTitle('{username}\'s Learn Request Queue')">Reset</button>
</p>
<p>
<h3>Colors:</h3>
<p>Click a color to change it</p>
<b>Background:</b><br>
Primary: <input type="color" id="color-bg-primary" value="<%= it.config.colors.bg.primary %>"></input><br>
Table: <input type="color" id="color-bg-table" value="<%= it.config.colors.bg.table %>"></input><br>
Navbar: <input type="color" id="color-bg-navbar" value="<%= it.config.colors.bg.navbar %>"></input><br>
Error: <input type="color" id="color-bg-error" value="<%= it.config.colors.bg.error %>"></input><br>
<br>
<b>Foreground:</b><br>
Primary: <input type="color" id="color-fg-primary" value="<%= it.config.colors.fg.primary %>"></input><br>
Link Hover: <input type="color" id="color-fg-ahover" value="<%= it.config.colors.fg.ahover %>"></input><br>
Title: <input type="color" id="color-fg-title" value="<%= it.config.colors.fg.title %>"></input><br>
<br>
<button onclick="updateColors(getColorObject())">Submit</button>
<button onclick="updateColors({bg:{error: '#ff0000',table: '#282828',navbar: '#666666',primary: '#444444'},fg: { title: '#eeeeee', ahover: '#ffffff', primary: '#dddddd'}})">Reset</button>
</p>
</div>
<hr>
<div>
<h3 style='margin-bottom: 0.5em'>Batch Jobs</h3>
<a href="#" onclick="cronRequest('processBans')">Force Refresh Banned Users (NYI)</a>
</div>
</div>
</div>
</div>
</body>
</html>