diff --git a/DatabaseSchema.md b/DatabaseSchema.md index bcd9949..f0d0785 100644 --- a/DatabaseSchema.md +++ b/DatabaseSchema.md @@ -207,6 +207,7 @@ [categoryVotes](#categoryVotes) [sponsorTimes](#sponsorTimes) [config](#config) +[tempVipLog](#tempVipLog) ### vote @@ -270,3 +271,11 @@ | index | field | | -- | :--: | | ratings_videoID | videoID, service, userID, timeSubmitted | + +### tempVipLog +| Name | Type | | +| -- | :--: | -- | +| issuerUserID | TEXT | not null | +| targetUserID | TEXT | not null | +| enabled | BOOLEAN | not null | +| updatedAt | INTEGER | not null | \ No newline at end of file diff --git a/databases/_upgrade_private_4.sql b/databases/_upgrade_private_4.sql index 1bc137c..008cb67 100644 --- a/databases/_upgrade_private_4.sql +++ b/databases/_upgrade_private_4.sql @@ -5,7 +5,7 @@ CREATE TABLE IF NOT EXISTS "ratings" ( "service" TEXT NOT NULL default 'YouTube', "type" INTEGER NOT NULL, "userID" TEXT NOT NULL, - "timeSubmitted" INTEGER NOT NULL, + "timeSubmitted" INTEGER NOT NULL, "hashedIP" TEXT NOT NULL ); diff --git a/databases/_upgrade_private_5.sql b/databases/_upgrade_private_5.sql new file mode 100644 index 0000000..3d98aee --- /dev/null +++ b/databases/_upgrade_private_5.sql @@ -0,0 +1,12 @@ +BEGIN TRANSACTION; + +CREATE TABLE IF NOT EXISTS "tempVipLog" ( + "issuerUserID" TEXT NOT NULL, + "targetUserID" TEXT NOT NULL, + "enabled" BOOLEAN NOT NULL, + "updatedAt" INTEGER NOT NULL +); + +UPDATE "config" SET value = 5 WHERE key = 'version'; + +COMMIT; \ No newline at end of file diff --git a/src/app.ts b/src/app.ts index 23ed171..3c84724 100644 --- a/src/app.ts +++ b/src/app.ts @@ -46,6 +46,7 @@ import { getChapterNames } from "./routes/getChapterNames"; import { postRating } from "./routes/ratings/postRating"; import { getRating } from "./routes/ratings/getRating"; import { postClearCache as ratingPostClearCache } from "./routes/ratings/postClearCache"; +import { addUserAsTempVIP } from "./routes/addUserAsTempVIP"; export function createServer(callback: () => void): Server { // Create a service (the app object is just a callback). @@ -117,6 +118,8 @@ function setupRoutes(router: Router) { //Endpoint used to make a user a VIP user with special privileges router.post("/api/addUserAsVIP", addUserAsVIP); + //Endpoint to add a user as a temporary VIP + router.post("/api/addUserAsTempVIP", addUserAsTempVIP); //Gets all the views added up for one userID //Useful to see how much one user has contributed diff --git a/src/routes/addUserAsTempVIP.ts b/src/routes/addUserAsTempVIP.ts new file mode 100644 index 0000000..6788fa2 --- /dev/null +++ b/src/routes/addUserAsTempVIP.ts @@ -0,0 +1,71 @@ +import { VideoID } from "../types/segments.model"; +import { YouTubeAPI } from "../utils/youtubeApi"; +import { APIVideoInfo } from "../types/youtubeApi.model"; +import { config } from "../config"; +import { getHashCache } from "../utils/getHashCache"; +import { privateDB } from "../databases/databases"; +import { Request, Response } from "express"; +import { isUserVIP } from "../utils/isUserVIP"; +import { HashedUserID } from "../types/user.model"; +import redis from "../utils/redis"; +import { tempVIPKey } from "../utils/redisKeys"; + +interface AddUserAsTempVIPRequest extends Request { + query: { + userID: HashedUserID; + adminUserID: string; + enabled: string; + channelVideoID: string; + } +} + +function getYouTubeVideoInfo(videoID: VideoID, ignoreCache = false): Promise { + return (config.newLeafURLs) ? YouTubeAPI.listVideos(videoID, ignoreCache) : null; +} + +const getChannelInfo = async (videoID: VideoID): Promise<{id: string | null, name: string | null }> => { + const videoInfo = await getYouTubeVideoInfo(videoID); + return { + id: videoInfo?.data?.authorId, + name: videoInfo?.data?.author + }; +}; + +export async function addUserAsTempVIP(req: AddUserAsTempVIPRequest, res: Response): Promise { + const { query: { userID, adminUserID } } = req; + + const enabled = req.query?.enabled === "true"; + const channelVideoID = req.query?.channelVideoID as VideoID; + + if (!userID || !adminUserID || !channelVideoID ) { + // invalid request + return res.sendStatus(400); + } + + // hash the issuer userID + const issuerUserID = await getHashCache(adminUserID); + // check if issuer is VIP + const issuerIsVIP = await isUserVIP(issuerUserID as HashedUserID); + if (!issuerIsVIP) { + return res.sendStatus(403); + } + + // check to see if this user is already a vip + const targetIsVIP = await isUserVIP(userID); + if (targetIsVIP) { + return res.sendStatus(409); + } + + const startTime = Date.now(); + const dayInSeconds = 86400; + const channelInfo = await getChannelInfo(channelVideoID); + + await privateDB.prepare("run", `INSERT INTO "tempVipLog" VALUES (?, ?, ?, ?)`, [adminUserID, userID, + enabled, startTime]); + if (enabled) { // add to redis + await redis.setAsyncEx(tempVIPKey(userID), channelInfo?.id, dayInSeconds); + } else { // delete key + await redis.delAsync(tempVIPKey(userID)); + } + + return res.sendStatus(200); +} \ No newline at end of file diff --git a/src/routes/voteOnSponsorTime.ts b/src/routes/voteOnSponsorTime.ts index fc0940d..ec70c11 100644 --- a/src/routes/voteOnSponsorTime.ts +++ b/src/routes/voteOnSponsorTime.ts @@ -322,7 +322,7 @@ export async function voteOnSponsorTime(req: Request, res: Response): Promise; set(key: string, value: string, callback?: Callback): void; setAsync?(key: string, value: string): Promise<{err: Error | null, reply: string | null}>; + setAsyncEx?(key: string, value: string, seconds: number): Promise<{err: Error | null, reply: string | null}>; delAsync?(...keys: [string]): Promise; close?(flush?: boolean): void; } @@ -18,6 +19,8 @@ let exportObject: RedisSB = { set: (key, value, callback) => callback(null, undefined), setAsync: () => new Promise((resolve) => resolve({ err: null, reply: undefined })), + setAsyncEx: () => + new Promise((resolve) => resolve({ err: null, reply: undefined })), delAsync: () => new Promise((resolve) => resolve(null)), }; @@ -29,6 +32,7 @@ if (config.redis) { exportObject.getAsync = (key) => new Promise((resolve) => client.get(key, (err, reply) => resolve({ err, reply }))); exportObject.setAsync = (key, value) => new Promise((resolve) => client.set(key, value, (err, reply) => resolve({ err, reply }))); + exportObject.setAsyncEx = (key, value, seconds) => new Promise((resolve) => client.setex(key, seconds, value, (err, reply) => resolve({ err, reply }))); exportObject.delAsync = (...keys) => new Promise((resolve) => client.del(keys, (err) => resolve(err))); exportObject.close = (flush) => client.end(flush); diff --git a/src/utils/redisKeys.ts b/src/utils/redisKeys.ts index 7afba40..38e675e 100644 --- a/src/utils/redisKeys.ts +++ b/src/utils/redisKeys.ts @@ -1,5 +1,5 @@ import { Service, VideoID, VideoIDHash } from "../types/segments.model"; -import { UserID } from "../types/user.model"; +import { HashedUserID, UserID } from "../types/user.model"; import { HashedValue } from "../types/hash.model"; import { Logger } from "./logger"; @@ -32,5 +32,5 @@ export function shaHashKey(singleIter: HashedValue): string { return `sha.hash.${singleIter}`; } -export const tempVIPKey = (userID: UserID): string => +export const tempVIPKey = (userID: HashedUserID): string => `vip.temp.${userID}`; \ No newline at end of file diff --git a/test/cases/tempVip.ts b/test/cases/tempVip.ts new file mode 100644 index 0000000..a054a12 --- /dev/null +++ b/test/cases/tempVip.ts @@ -0,0 +1,165 @@ +import { config } from "../../src/config"; +import { getHash } from "../../src/utils/getHash"; +import { tempVIPKey } from "../../src/utils/redisKeys"; +import { HashedUserID } from "../../src/types/user.model"; +import { client } from "../utils/httpClient"; +import { db, privateDB } from "../../src/databases/databases"; +import redis from "../../src/utils/redis"; +import assert from "assert"; + +// helpers +const getSegment = (UUID: string) => db.prepare("get", `SELECT "votes", "locked", "category" FROM "sponsorTimes" WHERE "UUID" = ?`, [UUID]); + +const permVIP = "tempVipPermOne"; +const publicPermVIP = getHash(permVIP) as HashedUserID; +const tempVIPOne = "tempVipTempOne"; +const publicTempVIPOne = getHash(tempVIPOne) as HashedUserID; +const UUID0 = "tempvip-uuid0"; +const UUID1 = "tempvip-uuid1"; + +const tempVIPEndpoint = "/api/addUserAsTempVIP"; +const addTempVIP = (enabled: boolean) => client({ + url: tempVIPEndpoint, + method: "POST", + params: { + userID: publicTempVIPOne, + adminUserID: permVIP, + channelVideoID: "channelid-convert", + enabled: enabled + } +}); +const voteEndpoint = "/api/voteOnSponsorTime"; +const postVote = (userID: string, UUID: string, type: number) => client({ + method: "POST", + url: voteEndpoint, + params: { + userID, + UUID, + type + } +}); +const postVoteCategory = (userID: string, UUID: string, category: string) => client({ + method: "POST", + url: voteEndpoint, + params: { + userID, + UUID, + category + } +}); +const checkUserVIP = async () => { + const { reply } = await redis.getAsync(tempVIPKey(publicTempVIPOne)); + return reply; +}; + +describe("tempVIP test", function() { + before(async function() { + if (!config.redis) this.skip(); + + const insertSponsorTimeQuery = 'INSERT INTO "sponsorTimes" ("videoID", "startTime", "endTime", "votes", "locked", "UUID", "userID", "timeSubmitted", "views", "category", "shadowHidden") VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)'; + await db.prepare("run", insertSponsorTimeQuery, ["channelid-convert", 0, 1, 0, 0, UUID0, "testman", 0, 50, "sponsor", 0]); + await db.prepare("run", insertSponsorTimeQuery, ["channelid-convert", 1, 9, 0, 1, "tempvip-submit", publicTempVIPOne, 0, 50, "sponsor", 0]); + await db.prepare("run", insertSponsorTimeQuery, ["otherchannel", 1, 9, 0, 1, UUID1, "testman", 0, 50, "sponsor", 0]); + + + await db.prepare("run", 'INSERT INTO "vipUsers" ("userID") VALUES (?)', [publicPermVIP]); + // clear redis if running consecutive tests + await redis.delAsync(tempVIPKey(publicTempVIPOne)); + }); + + it("Should update db version when starting the application", () => { + privateDB.prepare("get", "SELECT key, value FROM config where key = ?", ["version"]) + .then(row => { + assert.ok(row.value >= 5, `Versions are not at least 5. private is ${row.value}`); + }); + }); + it("User should not already be temp VIP", (done) => { + checkUserVIP() + .then(result => { + assert.ok(!result); + done(result); + }) + .catch(err => done(err)); + }); + it("Should be able to normal upvote as a user", (done) => { + postVote(tempVIPOne, UUID0, 1) + .then(async res => { + assert.strictEqual(res.status, 200); + const row = await getSegment(UUID0); + assert.strictEqual(row.votes, 1); + done(); + }) + .catch(err => done(err)); + }); + it("Should be able to add tempVIP", (done) => { + addTempVIP(true) + .then(async res => { + assert.strictEqual(res.status, 200); + const vip = await checkUserVIP(); + assert.ok(vip == "ChannelID"); + done(); + }) + .catch(err => done(err)); + }); + it("Should be able to VIP downvote", (done) => { + postVote(tempVIPOne, UUID0, 0) + .then(async res => { + assert.strictEqual(res.status, 200); + const row = await getSegment(UUID0); + assert.strictEqual(row.votes, -2); + done(); + }) + .catch(err => done(err)); + }); + it("Should be able to VIP lock", (done) => { + postVote(tempVIPOne, UUID0, 1) + .then(async res => { + assert.strictEqual(res.status, 200); + const row = await getSegment(UUID0); + assert.ok(row.votes > -2); + assert.strictEqual(row.locked, 1); + done(); + }) + .catch(err => done(err)); + }); + it("Should be able to VIP change category", (done) => { + postVoteCategory(tempVIPOne, UUID0, "filler") + .then(async res => { + assert.strictEqual(res.status, 200); + const row = await getSegment(UUID0); + assert.strictEqual(row.category, "filler"); + assert.strictEqual(row.locked, 1); + done(); + }) + .catch(err => done(err)); + }); + it("Should be able to remove tempVIP prematurely", (done) => { + addTempVIP(false) + .then(async res => { + assert.strictEqual(res.status, 200); + const vip = await checkUserVIP(); + done(vip); + }) + .catch(err => done(err)); + }); + it("Should not be able to VIP downvote", (done) => { + postVote(tempVIPOne, UUID1, 0) + .then(async res => { + assert.strictEqual(res.status, 200); + const row = await getSegment(UUID1); + assert.strictEqual(row.votes, 0); + done(); + }) + .catch(err => done(err)); + }); + it("Should not be able to VIP change category", (done) => { + postVoteCategory(tempVIPOne, UUID1, "filler") + .then(async res => { + assert.strictEqual(res.status, 200); + const row = await getSegment(UUID1); + assert.strictEqual(row.category, "sponsor"); + done(); + }) + .catch(err => done(err)); + }); +}); \ No newline at end of file diff --git a/test/youtubeMock.ts b/test/youtubeMock.ts index bb221e0..92ad482 100644 --- a/test/youtubeMock.ts +++ b/test/youtubeMock.ts @@ -47,6 +47,15 @@ export class YouTubeApiMock { ] } as APIVideoData }; + } else if (obj.id === "channelid-convert") { + return { + err: null, + data: { + title: "Video Lookup Title", + author: "ChannelAuthor", + authorId: "ChannelID" + } as APIVideoData + }; } else { return { err: null,