Compare commits
43 Commits
Author | SHA1 | Date |
---|---|---|
Dessa Simpson | dd423bdb34 | |
Dessa Simpson | ac953b0bb5 | |
Dessa Simpson | 90f672b288 | |
Dessa Simpson | 8b49d51e08 | |
Dessa Simpson | 7897fe6f3d | |
Dessa Simpson | 6e26650077 | |
Dessa Simpson | 891ccfae92 | |
Dessa Simpson | fc09fd4dd4 | |
Dessa Simpson | 347a6d2324 | |
Dessa Simpson | 632dc73ee9 | |
Dessa Simpson | 8483705ae4 | |
Dessa Simpson | 5e42236354 | |
Dessa Simpson | 6782ad2fb4 | |
Dessa Simpson | e4910f87d1 | |
Dessa Simpson | 28d5ce09dd | |
Dessa Simpson | b968f054d5 | |
Dessa Simpson | 84a22ccffd | |
Dessa Simpson | c634e763f3 | |
Dessa Simpson | 4607eaf307 | |
Dessa Simpson | 354eddb673 | |
Dessa Simpson | 6bc292f122 | |
Dessa Simpson | 2a87e0408e | |
Dessa Simpson | dbe7e4c52d | |
Dessa Simpson | 60defb7ea6 | |
Dessa Simpson | fcd8f2b197 | |
Dessa Simpson | ad435e0dba | |
Dessa Simpson | d2cf4aa1d2 | |
Dessa Simpson | 6495e1c8ef | |
Dessa Simpson | 1c34b3f013 | |
Dessa Simpson | 2e5762c029 | |
Dessa Simpson | 7a4c39353c | |
Dessa Simpson | 57991dc36a | |
Dessa Simpson | 6d559b5fe2 | |
Dessa Simpson | f69e3a821c | |
Dessa Simpson | 21650994d2 | |
Dessa Simpson | 35aa0914a2 | |
Dessa Simpson | 2751ccf846 | |
Dessa Simpson | aded3f4c77 | |
Dessa Simpson | af97647033 | |
Dessa Simpson | a86e8a5667 | |
Dessa Simpson | 42d2a4a78c | |
Dessa Simpson | 88955ba707 | |
Dessa Simpson | 52f73745b6 |
|
@ -1,4 +1,7 @@
|
||||||
Dockerfile
|
Dockerfile
|
||||||
|
Dockerfile.prod
|
||||||
|
docker-compose.yml
|
||||||
.dockerignore
|
.dockerignore
|
||||||
.git
|
.git
|
||||||
.gitignore
|
.gitignore
|
||||||
|
node_modules
|
||||||
|
|
|
@ -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"]
|
|
@ -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
|
|
@ -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,2);
|
INSERT INTO version (major,minor) VALUES (0,8);
|
||||||
|
|
|
@ -1,10 +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,
|
||||||
|
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)
|
PRIMARY KEY (rowLock)
|
||||||
);
|
);
|
||||||
|
|
||||||
INSERT INTO config (normalUserVotePoints,followerVotePoints,subscriberVotePoints)
|
INSERT INTO config (rowlock) VALUES (true);
|
||||||
VALUES (10,50,100);
|
|
||||||
|
|
|
@ -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');
|
|
@ -2,7 +2,5 @@ CREATE TABLE users (
|
||||||
userId int NOT NULL,
|
userId int NOT NULL,
|
||||||
displayName varchar NOT NULL,
|
displayName varchar NOT NULL,
|
||||||
imageUrl varchar,
|
imageUrl varchar,
|
||||||
isFollower boolean NOT NULL DEFAULT FALSE,
|
|
||||||
isSubscriber boolean NOT NULL DEFAULT FALSE,
|
|
||||||
PRIMARY KEY (userId)
|
PRIMARY KEY (userId)
|
||||||
);
|
);
|
||||||
|
|
|
@ -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)
|
||||||
)
|
);
|
||||||
|
|
|
@ -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(),
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
CREATE TABLE bans (
|
CREATE TABLE bans (
|
||||||
userid integer,
|
userid integer,
|
||||||
PRIMARY KEY (userid)
|
PRIMARY KEY (userid)
|
||||||
)
|
);
|
||||||
|
|
|
@ -0,0 +1,4 @@
|
||||||
|
CREATE TABLE follows (
|
||||||
|
userid integer,
|
||||||
|
PRIMARY KEY (userid)
|
||||||
|
);
|
|
@ -0,0 +1,4 @@
|
||||||
|
CREATE TABLE subscriptions (
|
||||||
|
userid integer,
|
||||||
|
PRIMARY KEY (userid)
|
||||||
|
);
|
|
@ -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
|
SELECT url,COALESCE(videoTitle,url) AS title,displayName AS requester,imageUrl,state,score,reqTimestamp FROM requests
|
||||||
JOIN requestMetadata USING (url)
|
JOIN requestMetadata USING (url)
|
||||||
JOIN scores USING (url)
|
JOIN scores USING (url)
|
||||||
JOIN users ON requests.requester = users.userid
|
JOIN users ON requests.requester = users.userid;
|
||||||
ORDER BY score DESC, reqTimestamp ASC;
|
|
||||||
|
|
||||||
CREATE OR REPLACE FUNCTION get_requests_voted(votinguserid int)
|
CREATE OR REPLACE FUNCTION get_requests_voted(votinguserid int)
|
||||||
RETURNS TABLE (
|
RETURNS TABLE (
|
||||||
|
@ -21,8 +20,7 @@ CREATE OR REPLACE FUNCTION get_requests_voted(votinguserid int)
|
||||||
SELECT url,title,requester,imageUrl,state,score,reqTimestamp,
|
SELECT url,title,requester,imageUrl,state,score,reqTimestamp,
|
||||||
(CASE WHEN votes.userid IS NULL THEN FALSE ELSE TRUE END) AS voted
|
(CASE WHEN votes.userid IS NULL THEN FALSE ELSE TRUE END) AS voted
|
||||||
FROM requests_vw
|
FROM requests_vw
|
||||||
LEFT JOIN votes ON (requests_vw.url = votes.requesturl AND votes.userid = votinguserid)
|
LEFT JOIN votes ON (requests_vw.url = votes.requesturl AND votes.userid = votinguserid);
|
||||||
ORDER BY score DESC, reqTimestamp ASC;
|
|
||||||
$$;
|
$$;
|
||||||
|
|
||||||
/*
|
/*
|
||||||
|
@ -40,18 +38,20 @@ CREATE OR REPLACE VIEW vote_score_vw AS
|
||||||
COUNT(votes.requesturl) AS count,
|
COUNT(votes.requesturl) AS count,
|
||||||
COALESCE(
|
COALESCE(
|
||||||
SUM(CASE
|
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
|
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
|
THEN votepoints.followervotepoints
|
||||||
WHEN users.issubscriber = TRUE
|
WHEN subscriptions.userid IS NOT NULL
|
||||||
THEN votepoints.subscribervotepoints
|
THEN votepoints.subscribervotepoints
|
||||||
END), 0
|
END), 0
|
||||||
) AS votescore
|
) AS votescore
|
||||||
FROM requests
|
FROM requests
|
||||||
LEFT JOIN votes ON votes.requesturl = requests.url
|
LEFT JOIN votes ON votes.requesturl = requests.url
|
||||||
LEFT JOIN users on votes.userid = users.userid
|
LEFT JOIN bans ON votes.userid = bans.userid
|
||||||
LEFT JOIN bans ON users.userid = bans.userid
|
LEFT JOIN follows ON votes.userid = follows.userid
|
||||||
|
LEFT JOIN subscriptions ON votes.userid = subscriptions.userid
|
||||||
CROSS JOIN votepoints
|
CROSS JOIN votepoints
|
||||||
WHERE bans.userid IS NULL
|
WHERE bans.userid IS NULL
|
||||||
GROUP BY url;
|
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
|
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);
|
||||||
|
|
|
@ -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)
|
||||||
|
@ -36,3 +44,11 @@ CREATE OR REPLACE PROCEDURE update_request_score_modifier(updateurl varchar, sco
|
||||||
UPDATE scores SET scoreModifier = scoreModifier + scoreDiff WHERE url = updateurl;
|
UPDATE scores SET scoreModifier = scoreModifier + scoreDiff WHERE url = updateurl;
|
||||||
CALL update_scores();
|
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();
|
||||||
|
$$;
|
||||||
|
|
|
@ -1,8 +1,16 @@
|
||||||
INSERT INTO users (userid,displayName,isFollower,isSubscriber) VALUES
|
INSERT INTO users (userid,displayName) VALUES
|
||||||
(001,'TestUser',false,false),
|
(001,'TestUser'),
|
||||||
(002,'TestFollower',true,false),
|
(002,'TestFollower'),
|
||||||
(003,'TestSubscriber',true,true),
|
(003,'TestSubscriber'),
|
||||||
(004,'TestSubNonFollower',false,true);
|
(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=dQw4w9WgXcQ',001);
|
||||||
CALL add_request('https://www.youtube.com/watch?v=C5oeWHngDS4',002);
|
CALL add_request('https://www.youtube.com/watch?v=C5oeWHngDS4',002);
|
||||||
|
|
|
@ -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;
|
|
@ -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;
|
|
@ -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;
|
|
@ -0,0 +1,5 @@
|
||||||
|
BEGIN;
|
||||||
|
|
||||||
|
UPDATE version SET minor = 6;
|
||||||
|
|
||||||
|
COMMIT;
|
|
@ -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;
|
|
@ -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;
|
|
@ -1,7 +1,7 @@
|
||||||
version: '3.8'
|
version: '3.8'
|
||||||
services:
|
services:
|
||||||
app:
|
app:
|
||||||
container_name: learn-request-queue
|
container_name: lrq
|
||||||
build: .
|
build: .
|
||||||
depends_on:
|
depends_on:
|
||||||
- db
|
- db
|
||||||
|
@ -11,7 +11,7 @@ services:
|
||||||
- "80:3000"
|
- "80:3000"
|
||||||
env_file: .env
|
env_file: .env
|
||||||
db:
|
db:
|
||||||
container_name: learn-request-queue-db
|
container_name: lrqdb
|
||||||
image: postgres
|
image: postgres
|
||||||
ports:
|
ports:
|
||||||
- "5432:5432"
|
- "5432:5432"
|
||||||
|
|
202
public/main.js
202
public/main.js
|
@ -1,28 +1,52 @@
|
||||||
var requestsDiv = document.getElementById("requests");
|
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) {
|
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>`;
|
||||||
|
@ -44,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 = "";
|
||||||
|
@ -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) {
|
function addRequestErr(msg) {
|
||||||
document.getElementById('addRequestError').style.display = "inline-block";
|
document.getElementById('addRequestError').style.display = "inline-block";
|
||||||
document.getElementById('addRequestError').innerText = msg;
|
document.getElementById('addRequestError').innerText = msg;
|
||||||
|
@ -85,33 +132,46 @@ function updateRequestErrReset() {
|
||||||
document.getElementById('updateRequestError').innerText = "";
|
document.getElementById('updateRequestError').innerText = "";
|
||||||
}
|
}
|
||||||
|
|
||||||
function showMessage(msg) {
|
function streamerSettingsErr(msg) {
|
||||||
document.getElementById("messageModalText").innerText = 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("addRequestModal").style.display = "none";
|
||||||
document.getElementById("updateRequestModal").style.display = "none";
|
document.getElementById("updateRequestModal").style.display = "none";
|
||||||
document.getElementById("deleteRequestModal").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("modalBackground").style.display = "flex";
|
||||||
document.getElementById("messageModal").style.display = "block";
|
document.getElementById("messageModal").style.display = "block";
|
||||||
}
|
}
|
||||||
|
|
||||||
function closeMessageModal() {
|
|
||||||
document.getElementById("modalBackground").style.display = "none";
|
|
||||||
document.getElementById("messageModal").style.display = "none";
|
|
||||||
}
|
|
||||||
|
|
||||||
function openAddRequestModal() {
|
function openAddRequestModal() {
|
||||||
|
hideModals();
|
||||||
document.getElementById("modalBackground").style.display = "flex";
|
document.getElementById("modalBackground").style.display = "flex";
|
||||||
document.getElementById("updateRequestModal").style.display = "none";
|
|
||||||
document.getElementById("messageModal").style.display = "none";
|
|
||||||
document.getElementById("addRequestModal").style.display = "block";
|
document.getElementById("addRequestModal").style.display = "block";
|
||||||
}
|
}
|
||||||
|
|
||||||
function closeAddRequestModal() {
|
|
||||||
document.getElementById("modalBackground").style.display = "none";
|
|
||||||
document.getElementById("addRequestModal").style.display = "none";
|
|
||||||
}
|
|
||||||
|
|
||||||
function openUpdateRequestModal(tr) {
|
function openUpdateRequestModal(tr) {
|
||||||
|
hideModals();
|
||||||
var url = tr.getElementsByClassName('request-link')[0].firstChild.href;
|
var url = tr.getElementsByClassName('request-link')[0].firstChild.href;
|
||||||
var score = tr.getElementsByClassName('request-score')[0].innerText;
|
var score = tr.getElementsByClassName('request-score')[0].innerText;
|
||||||
var state = tr.getElementsByClassName('request-state')[0].innerText;
|
var state = tr.getElementsByClassName('request-state')[0].innerText;
|
||||||
|
@ -120,18 +180,12 @@ function openUpdateRequestModal(tr) {
|
||||||
document.getElementById("updateRequestModalCurrentScore").innerText = score;
|
document.getElementById("updateRequestModalCurrentScore").innerText = score;
|
||||||
document.querySelector(`#updateRequestStateSelect [value="${state}"]`).selected = true;
|
document.querySelector(`#updateRequestStateSelect [value="${state}"]`).selected = true;
|
||||||
document.getElementById("scoreModifierInput").value = 0;
|
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("modalBackground").style.display = "flex";
|
||||||
document.getElementById("updateRequestModal").style.display = "block";
|
document.getElementById("updateRequestModal").style.display = "block";
|
||||||
}
|
}
|
||||||
|
|
||||||
function closeUpdateRequestModal() {
|
|
||||||
document.getElementById("modalBackground").style.display = "none";
|
|
||||||
document.getElementById("updateRequestModal").style.display = "none";
|
|
||||||
}
|
|
||||||
|
|
||||||
function openDeleteRequestModal(url) {
|
function openDeleteRequestModal(url) {
|
||||||
|
hideModals();
|
||||||
document.getElementById("updateRequestUrl").href = url;
|
document.getElementById("updateRequestUrl").href = url;
|
||||||
document.getElementById("updateRequestUrl").innerText = url;
|
document.getElementById("updateRequestUrl").innerText = url;
|
||||||
document.getElementById("messageModal").style.display = "none";
|
document.getElementById("messageModal").style.display = "none";
|
||||||
|
@ -141,17 +195,21 @@ function openDeleteRequestModal(url) {
|
||||||
document.getElementById("deleteRequestModal").style.display = "block";
|
document.getElementById("deleteRequestModal").style.display = "block";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Returns to update request modal
|
||||||
function closeDeleteRequestModal() {
|
function closeDeleteRequestModal() {
|
||||||
|
hideModals();
|
||||||
document.getElementById("deleteRequestModal").style.display = "none";
|
document.getElementById("deleteRequestModal").style.display = "none";
|
||||||
document.getElementById("updateRequestModal").style.display = "block";
|
document.getElementById("updateRequestModal").style.display = "block";
|
||||||
}
|
}
|
||||||
|
|
||||||
function closeAllModals() {
|
function openStreamerSettingsModal() {
|
||||||
document.getElementById("messageModal").style.display = "none";
|
hideModals();
|
||||||
document.getElementById("addRequestModal").style.display = "none";
|
document.getElementById("modalBackground").style.display = "flex";
|
||||||
document.getElementById("updateRequestModal").style.display = "none";
|
document.getElementById("streamerSettingsModal").style.display = "block";
|
||||||
document.getElementById("deleteRequestModal").style.display = "none";
|
}
|
||||||
document.getElementById("modalBackground").style.display = "none";
|
|
||||||
|
function cronRequest(job) {
|
||||||
|
if (!cronJobs.includes(job)) throw new Error("Request for invalid job");
|
||||||
}
|
}
|
||||||
|
|
||||||
const validUrlRegexes = [
|
const validUrlRegexes = [
|
||||||
|
@ -178,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);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
@ -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();
|
updateTable();
|
||||||
|
|
||||||
document.addEventListener("keydown", function onEvent(event) {
|
document.addEventListener("keydown", function onEvent(event) {
|
||||||
if (event.key === "Escape") {
|
if (event.key === "Escape") {
|
||||||
closeMessageModal();
|
closeAllModals();
|
||||||
closeAddRequestModal();
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
document.getElementById("modalBackground").addEventListener("click", (e) => { if (e.target === e.currentTarget) 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");
|
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();
|
||||||
}
|
}
|
||||||
|
|
|
@ -12,6 +12,12 @@ button, input, select {
|
||||||
font-size: 100%;
|
font-size: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
input[type="color"] {
|
||||||
|
border: none;
|
||||||
|
padding: 0px;
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
|
||||||
a {
|
a {
|
||||||
color: #ddd;
|
color: #ddd;
|
||||||
}
|
}
|
||||||
|
@ -105,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;
|
||||||
|
@ -117,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 {
|
||||||
|
@ -179,3 +200,18 @@ div#nav-userpic {
|
||||||
#deleteRequestLink {
|
#deleteRequestLink {
|
||||||
color: #f00;
|
color: #f00;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#streamerSettingsMain {
|
||||||
|
max-height: 65vh;
|
||||||
|
overflow-y: scroll;
|
||||||
|
}
|
||||||
|
|
||||||
|
#votepoints {
|
||||||
|
padding: 0 1.5em;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
#sortDir {
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
330
src/app.ts
330
src/app.ts
|
@ -2,13 +2,14 @@ import * as config from "./config";
|
||||||
import * as requests from "./requests";
|
import * as requests from "./requests";
|
||||||
import * as twitch from "./twitch";
|
import * as twitch from "./twitch";
|
||||||
import * as queries from "./queries";
|
import * as queries from "./queries";
|
||||||
import { log, LogLevel } from "./logging"
|
|
||||||
import { URLSearchParams } from "url";
|
import { URLSearchParams } from "url";
|
||||||
import express from "express";
|
import express from "express";
|
||||||
import session from "express-session";
|
import session from "express-session";
|
||||||
import pg from "pg";
|
import pg from "pg";
|
||||||
import pgSessionStore from "connect-pg-simple";
|
import pgSessionStore from "connect-pg-simple";
|
||||||
import fetch, { Response as FetchResponse } from "node-fetch";
|
import fetch, { Response as FetchResponse } from "node-fetch";
|
||||||
|
import { log, LogLevel } from "./logging"
|
||||||
|
import cron from "./cron";
|
||||||
import db from "./db";
|
import db from "./db";
|
||||||
import errorHandler from "./errors";
|
import errorHandler from "./errors";
|
||||||
import * as version from "./version";
|
import * as version from "./version";
|
||||||
|
@ -26,8 +27,10 @@ async function validateApiToken(session: Express.Session) {
|
||||||
}
|
}
|
||||||
|
|
||||||
const app = express();
|
const app = express();
|
||||||
|
app.use(version.checkVersionMiddleware);
|
||||||
app.use(express.static('public'));
|
app.use(express.static('public'));
|
||||||
app.use(express.urlencoded({extended: false}));
|
app.use(express.urlencoded({extended: false}));
|
||||||
|
app.use(express.json());
|
||||||
app.use(session({
|
app.use(session({
|
||||||
secret: config.sessionSecret,
|
secret: config.sessionSecret,
|
||||||
saveUninitialized: false,
|
saveUninitialized: false,
|
||||||
|
@ -39,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));
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
@ -76,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.");
|
||||||
|
@ -187,6 +245,139 @@ app.post("/api/updateRequestScoreModifier", async (request, response) => {
|
||||||
.catch((e: any) => errorHandler(request,response,e));
|
.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 '&';
|
||||||
|
case '<':
|
||||||
|
return '<';
|
||||||
|
case '>':
|
||||||
|
return '>';
|
||||||
|
case '"':
|
||||||
|
return '"';
|
||||||
|
case "'":
|
||||||
|
return ''';
|
||||||
|
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) => {
|
app.post("/api/deleteRequest", async (request, response) => {
|
||||||
if (request.session) await validateApiToken(request.session);
|
if (request.session) await validateApiToken(request.session);
|
||||||
if (!request.session || !request.session.user) {
|
if (!request.session || !request.session.user) {
|
||||||
|
@ -264,6 +455,37 @@ app.post("/api/deleteVote", async (request,response) => {
|
||||||
.catch((e: any) => errorHandler(request,response,e));
|
.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
|
// Twitch callback
|
||||||
app.get("/callback", async (request, response) => {
|
app.get("/callback", async (request, response) => {
|
||||||
if (request.query.error) {
|
if (request.query.error) {
|
||||||
|
@ -300,7 +522,7 @@ app.get("/callback", async (request, response) => {
|
||||||
app.get("/", async (request, response) => {
|
app.get("/", async (request, response) => {
|
||||||
if (request.session) await validateApiToken(request.session);
|
if (request.session) await validateApiToken(request.session);
|
||||||
var streamerInfo = await db.query(queries.getStreamerInfo).then((result: pg.QueryResult) => result.rows[0]);
|
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') {
|
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`);
|
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;
|
return;
|
||||||
|
@ -311,9 +533,11 @@ app.get("/", async (request, response) => {
|
||||||
clientId: config.twitchClientId,
|
clientId: config.twitchClientId,
|
||||||
urlPrefix: config.urlPrefix,
|
urlPrefix: config.urlPrefix,
|
||||||
streamerName: streamerInfo['displayname'],
|
streamerName: streamerInfo['displayname'],
|
||||||
streamerProfilePicture: streamerInfo['imageurl']
|
streamerProfilePicture: streamerInfo['imageurl'],
|
||||||
|
config: streamerConfig,
|
||||||
});
|
});
|
||||||
} else {
|
} 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', {
|
response.render('main.eta', {
|
||||||
loggedIn: true,
|
loggedIn: true,
|
||||||
userName: request.session.user.display_name,
|
userName: request.session.user.display_name,
|
||||||
|
@ -321,72 +545,36 @@ app.get("/", async (request, response) => {
|
||||||
validStates: validStates,
|
validStates: validStates,
|
||||||
isStreamer: streamerInfo['userid'] == request.session.user.id,
|
isStreamer: streamerInfo['userid'] == request.session.user.id,
|
||||||
streamerName: streamerInfo['displayname'],
|
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
|
// Streamer Panel
|
||||||
//app.get("/streamer/", async (request, response) => {
|
//app.get("/streamer/", async (request, response) => {
|
||||||
//
|
//
|
||||||
//});
|
//});
|
||||||
|
|
||||||
// Logout
|
// Logout
|
||||||
app.get("/logout", async (request, response) => request.session!.destroy(() => response.redirect(307, '/')));
|
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();
|
|
||||||
|
|
||||||
// Check version then listen
|
// Check version then listen
|
||||||
version.checkVersion().then(_ => app.listen(config.port, () => {
|
version.checkVersion().then(_ => app.listen(config.port, () => {
|
||||||
|
cron.run();
|
||||||
|
setInterval(cron.run,config.cronInterval);
|
||||||
console.log(`Listening on port ${config.port}`);
|
console.log(`Listening on port ${config.port}`);
|
||||||
}));
|
}))
|
||||||
|
.catch((e) => {
|
||||||
|
log(LogLevel.ERROR,e)
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
|
|
|
@ -36,6 +36,13 @@ if (typeof process.env.YOUTUBE_SECRET == 'undefined') {
|
||||||
}
|
}
|
||||||
export const youtubeSecret: string = process.env.YOUTUBE_SECRET!;
|
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)
|
console.log("Running with logLevel = " + logLevel)
|
||||||
|
|
|
@ -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}
|
|
@ -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
|
||||||
|
}
|
|
@ -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();
|
||||||
|
}
|
||||||
|
}
|
|
@ -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']);
|
||||||
|
}
|
||||||
|
}
|
|
@ -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();
|
||||||
|
}
|
||||||
|
}
|
|
@ -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();
|
||||||
|
}
|
||||||
|
}
|
|
@ -10,5 +10,5 @@ export enum LogLevel {
|
||||||
|
|
||||||
export async function log(logLevel: LogLevel, logMessage: any) {
|
export async function log(logLevel: LogLevel, logMessage: any) {
|
||||||
if (config.logLevel >= logLevel)
|
if (config.logLevel >= logLevel)
|
||||||
console.log(LogLevel[logLevel].padStart(7) + ' | ' + logMessage)
|
console.log(new Date().toISOString() + LogLevel[logLevel].padStart(7) + ' | ' + logMessage)
|
||||||
}
|
}
|
||||||
|
|
|
@ -26,40 +26,46 @@ export const getStreamerInfo = {
|
||||||
text: "SELECT userid,displayname,imageurl FROM streamer_user_vw"
|
text: "SELECT userid,displayname,imageurl FROM streamer_user_vw"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const getConfig = {
|
||||||
|
name: "getConfig",
|
||||||
|
text: "SELECT * FROM config"
|
||||||
|
}
|
||||||
|
|
||||||
export const updateStreamer = {
|
export const updateStreamer = {
|
||||||
name: "updateStreamer",
|
name: "updateStreamer",
|
||||||
text: "INSERT INTO streamer (userid,tokenPair) VALUES ($1,$2)\
|
text: "INSERT INTO streamer (userid,tokenPair) VALUES ($1,$2)\
|
||||||
ON CONFLICT (userid) DO UPDATE SET tokenPair = $2"
|
ON CONFLICT (userid) DO UPDATE SET tokenPair = $2"
|
||||||
}
|
}
|
||||||
|
|
||||||
// Request-related queries
|
export const updatePageTitle = {
|
||||||
export const getRequests = {
|
name: "updatePageTitle",
|
||||||
name: "getRequests",
|
text: "UPDATE config SET title = $1"
|
||||||
text: "SELECT * FROM requests_vw \
|
|
||||||
JOIN states ON requests_vw.state = states.state \
|
|
||||||
WHERE active LIMIT $1"
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const getRequestsVoted = {
|
export const updateColors = {
|
||||||
name: "getRequestsVoted",
|
name: "updateColors",
|
||||||
text: "SELECT * FROM get_requests_voted($2) \
|
text: "UPDATE config SET colors = $1"
|
||||||
JOIN states ON get_requests_voted.state = states.state \
|
|
||||||
WHERE active LIMIT $1"
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const getAllRequests = {
|
export const updateVotePoints = {
|
||||||
name: "getAllRequests",
|
name: "updateVotePoints",
|
||||||
text: "SELECT * FROM requests_vw LIMIT $1"
|
text: "CALL update_vote_points($1,$2,$3)"
|
||||||
}
|
}
|
||||||
|
|
||||||
export const getAllRequestsVoted = {
|
export const getRequestsTotal = {
|
||||||
name: "getAllRequestsVoted",
|
name: "getRequestsTotal",
|
||||||
text: "SELECT * FROM get_requests_voted($2) LIMIT $1"
|
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 = {
|
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 = {
|
||||||
|
@ -111,3 +117,21 @@ export const getDbVersion = {
|
||||||
name: "getDbVersion",
|
name: "getDbVersion",
|
||||||
text: "SELECT get_version()"
|
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"
|
||||||
|
}
|
||||||
|
|
|
@ -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 () => {
|
||||||
|
|
|
@ -2,7 +2,6 @@ import * as config from "./config";
|
||||||
import * as queries from "./queries";
|
import * as queries from "./queries";
|
||||||
import { log, LogLevel } from "./logging"
|
import { log, LogLevel } from "./logging"
|
||||||
import fetch, { Response as FetchResponse } from "node-fetch";
|
import fetch, { Response as FetchResponse } from "node-fetch";
|
||||||
import pg from "pg";
|
|
||||||
import db from "./db";
|
import db from "./db";
|
||||||
|
|
||||||
export interface TokenPair {
|
export interface TokenPair {
|
||||||
|
@ -10,6 +9,11 @@ export interface TokenPair {
|
||||||
refresh_token: string;
|
refresh_token: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface StreamerUserIdTokenPair {
|
||||||
|
userid: number
|
||||||
|
tokenpair: TokenPair
|
||||||
|
}
|
||||||
|
|
||||||
// Refresh the API token. Returns true on success and false on failure.
|
// Refresh the API token. Returns true on success and false on failure.
|
||||||
async function refreshApiToken(tokens: TokenPair): Promise<boolean> {
|
async function refreshApiToken(tokens: TokenPair): Promise<boolean> {
|
||||||
log(LogLevel.DEBUG,`Call: twitch.refreshApiToken(${JSON.stringify(tokens,null,2)})`);
|
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) {
|
if (res.status == 200) {
|
||||||
log(LogLevel.INFO,"twitch.refreshApiToken: Refresh returned success.");
|
log(LogLevel.INFO,"twitch.refreshApiToken: Refresh returned success.");
|
||||||
var data = await (res.json() as Promise<TokenPair>);
|
var data = await (res.json() as Promise<TokenPair>);
|
||||||
log(LogLevel.DEBUG, "Returned data:")
|
log(LogLevel.DEBUG, "Returned data:");
|
||||||
log(LogLevel.DEBUG, data)
|
log(LogLevel.DEBUG, JSON.stringify(data,null,2));
|
||||||
tokens.access_token = data.access_token;
|
tokens.access_token = data.access_token;
|
||||||
tokens.refresh_token = data.refresh_token;
|
tokens.refresh_token = data.refresh_token;
|
||||||
return true;
|
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 })
|
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):");
|
||||||
|
@ -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})`);
|
log(LogLevel.DEBUG,`Call: twitch.streamerApiRequest(${endpoint})`);
|
||||||
var streamer = await db.query(queries.getStreamerIdToken).then((result: pg.QueryResult) => result.rows[0]);
|
var originaltoken = streamer.tokenpair.access_token;
|
||||||
var tokenpair = streamer.tokenpair;
|
log(LogLevel.DEBUG,"Original token: " + originaltoken);
|
||||||
var originaltoken = tokenpair.access_token;
|
var response = await apiRequest(streamer.tokenpair,endpoint);
|
||||||
log(LogLevel.DEBUG,"Original token: " + tokenpair.access_token);
|
log(LogLevel.DEBUG,"New token: " + streamer.tokenpair.access_token);
|
||||||
var response = await apiRequest(tokenpair,endpoint);
|
if (streamer.tokenpair.access_token != originaltoken)
|
||||||
log(LogLevel.DEBUG,"New token: " + tokenpair.access_token);
|
await db.query(Object.assign(queries.updateStreamer,
|
||||||
if (tokenpair.access_token != originaltoken) await db.query(Object.assign(queries.updateStreamer,{ values: [streamer.userid,tokenpair] }))
|
{ values: [streamer.userid,streamer.tokenpair] }))
|
||||||
return response;
|
return response;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -1,15 +1,30 @@
|
||||||
import * as queries from "./queries";
|
import * as queries from "./queries";
|
||||||
|
import { log, LogLevel } from "./logging"
|
||||||
|
import express from "express";
|
||||||
import pg from "pg";
|
import pg from "pg";
|
||||||
import db from "./db";
|
import db from "./db";
|
||||||
|
|
||||||
var versionMajor = 0;
|
var versionMajor = 0;
|
||||||
var versionMinor = 2;
|
var versionMinor = 8;
|
||||||
var versionPatch = 1;
|
var versionPatch = 0;
|
||||||
|
|
||||||
export function getVersion() {
|
export function getVersion() {
|
||||||
return `${versionMajor}.${versionMinor}.${versionPatch}`
|
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() {
|
export async function checkVersion() {
|
||||||
var dbver = await db.query(queries.getDbVersion).then((result: pg.QueryResult) => result.rows[0]['get_version']);
|
var dbver = await db.query(queries.getDbVersion).then((result: pg.QueryResult) => result.rows[0]['get_version']);
|
||||||
if (dbver != `${versionMajor}.${versionMinor}`) {
|
if (dbver != `${versionMajor}.${versionMinor}`) {
|
||||||
|
|
|
@ -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 %>;
|
||||||
|
}
|
121
views/main.eta
121
views/main.eta
|
@ -1,8 +1,9 @@
|
||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html>
|
<html>
|
||||||
<head>
|
<head>
|
||||||
<link rel=stylesheet href=style.css />
|
<link rel=stylesheet href=/style.css />
|
||||||
<title><%= it.streamerName %>'s Learn Request Queue</title>
|
<link rel=stylesheet href=/colors.css />
|
||||||
|
<title><%~ it.config.title.replace('{username}',it.streamerName) %></title>
|
||||||
<script>
|
<script>
|
||||||
window.loggedIn = <%= it.loggedIn %>;
|
window.loggedIn = <%= it.loggedIn %>;
|
||||||
window.validStates = <%~ it.validStates %>;
|
window.validStates = <%~ it.validStates %>;
|
||||||
|
@ -13,10 +14,13 @@
|
||||||
<body>
|
<body>
|
||||||
<div id="navbar">
|
<div id="navbar">
|
||||||
<div id="nav-streamerpic"><a href="https://twitch.tv/<%= it.streamerName %>"><img src="<%= it.streamerProfilePicture %>" /></a></div>
|
<div id="nav-streamerpic"><a href="https://twitch.tv/<%= it.streamerName %>"><img src="<%= it.streamerProfilePicture %>" /></a></div>
|
||||||
<div id="nav-title"><a href="https://twitch.tv/<%= it.streamerName %>"><%= it.streamerName %></a>'s Learn Request Queue</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>
|
<div id="nav-requests"><a href="/">Requests</a></div>
|
||||||
<%- if (it.loggedIn) { -%>
|
<%- if (it.loggedIn) { -%>
|
||||||
<div id="nav-addrequest"><a href="#" onclick="openAddRequestModal()">Add Request</a></div>
|
<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-userpic"><img src="<%= it.userProfilePicture %>" /></div>
|
||||||
<div id="nav-username"><%= it.userName %></div>
|
<div id="nav-username"><%= it.userName %></div>
|
||||||
<div id="nav-logout"><a href="/logout">Logout</a></div>
|
<div id="nav-logout"><a href="/logout">Logout</a></div>
|
||||||
|
@ -25,24 +29,52 @@
|
||||||
<%- } %>
|
<%- } %>
|
||||||
</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)"><<</button>
|
||||||
<option selected="selected">10</option>
|
<button id="pageBtnPrev" onclick="goToPage(currentPage-1)"><</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)">></button>
|
||||||
|
<button id="pageBtnLast" onclick="goToPage(totalPages)">>></button>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div id="modalBackground">
|
<div id="modalBackground">
|
||||||
<div class="modal" id="messageModal">
|
<div class="modal" id="messageModal">
|
||||||
<div class="modalClose"><a href="#" onclick="closeMessageModal()">×</a></div>
|
<div class="modalClose"><a href="#" onclick="closeAllModals()">×</a></div>
|
||||||
<span id="messageModalText"></span>
|
<span id="messageModalText"></span>
|
||||||
</div>
|
</div>
|
||||||
<div class="modal" id="addRequestModal">
|
<div class="modal" id="addRequestModal">
|
||||||
<div class="modalClose"><a href="#" onclick="closeAddRequestModal()">×</a></div>
|
<div class="modalClose"><a href="#" onclick="closeAllModals()">×</a></div>
|
||||||
<h1>Add Request</h1>
|
<h1>Add Request</h1>
|
||||||
<div class="error" id="addRequestError"></div>
|
<div class="error" id="addRequestError"></div>
|
||||||
<span id="addRequestInputContainer">
|
<span id="addRequestInputContainer">
|
||||||
|
@ -52,7 +84,7 @@
|
||||||
Currently, only Youtube links are accepted.
|
Currently, only Youtube links are accepted.
|
||||||
</div>
|
</div>
|
||||||
<div class="modal" id="updateRequestModal">
|
<div class="modal" id="updateRequestModal">
|
||||||
<div class="modalClose"><a href="#" onclick="closeAddRequestModal()">×</a></div>
|
<div class="modalClose"><a href="#" onclick="closeAllModals()">×</a></div>
|
||||||
<h2>Update Request</h2>
|
<h2>Update Request</h2>
|
||||||
<div class="error" id="updateRequestError"></div>
|
<div class="error" id="updateRequestError"></div>
|
||||||
<br>
|
<br>
|
||||||
|
@ -83,7 +115,7 @@
|
||||||
</div>
|
</div>
|
||||||
<br>
|
<br>
|
||||||
<div>
|
<div>
|
||||||
<a id="updateMetadataLink" href="#" onclick="updateRequestMetadata(
|
<a href="#" onclick="updateRequestMetadata(
|
||||||
document.getElementById('updateRequestUrl').innerText
|
document.getElementById('updateRequestUrl').innerText
|
||||||
)">Update Request Metadata</a>
|
)">Update Request Metadata</a>
|
||||||
<br>
|
<br>
|
||||||
|
@ -111,6 +143,63 @@
|
||||||
<button onclick="closeDeleteRequestModal()">No</button>
|
<button onclick="closeDeleteRequestModal()">No</button>
|
||||||
<button onclick="deleteRequest(document.getElementById('updateRequestUrl').innerText)">Yes</button>
|
<button onclick="deleteRequest(document.getElementById('updateRequestUrl').innerText)">Yes</button>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="modal" id="streamerSettingsModal">
|
||||||
|
<div class="modalClose"><a href="#" onclick="closeAllModals()">×</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>
|
</div>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
Loading…
Reference in New Issue