diff --git a/src/app.ts b/src/app.ts index 3f2b993..ee0ab8c 100644 --- a/src/app.ts +++ b/src/app.ts @@ -45,6 +45,7 @@ import { youtubeApiProxy } from "./routes/youtubeApiProxy"; 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"; export function createServer(callback: () => void): Server { // Create a service (the app object is just a callback). @@ -193,6 +194,7 @@ function setupRoutes(router: Router) { // ratings router.get("/api/ratings/rate/:prefix", getRating); router.post("/api/ratings/rate", postRateEndpoints); + router.post("/api/ratings/clearCache", ratingPostClearCache); if (config.postgres) { router.get("/database", (req, res) => dumpDatabase(req, res, true)); diff --git a/src/routes/postClearCache.ts b/src/routes/postClearCache.ts index 91d4184..4d8ee82 100644 --- a/src/routes/postClearCache.ts +++ b/src/routes/postClearCache.ts @@ -39,7 +39,7 @@ export async function postClearCache(req: Request, res: Response): Promise : [req.query.type] : [RatingType.Upvote, RatingType.Downvote]; if (!Array.isArray(types)) { - return res.status(400).send("Categories parameter does not match format requirements."); + return res.status(400).send("Types parameter does not match format requirements."); } types = types.map((type) => parseInt(type as unknown as string, 10)); } catch(error) { - return res.status(400).send("Bad parameter: categories (invalid JSON)"); + return res.status(400).send("Bad parameter: types (invalid JSON)"); } const service: Service = getService(req.query.service, req.body.service); @@ -53,20 +53,15 @@ export async function getRating(req: Request, res: Response): Promise type: rating.type, count: rating.count })); - - if (ratings) { - res.status(200); - } else { - res.status(404); - } - return res.send(ratings ?? []); + return res.status((ratings.length) ? 200 : 404) + .send(ratings ?? []); } catch (err) { Logger.error(err as string); return res.sendStatus(500); } } -async function getRatings(hashPrefix: VideoIDHash, service: Service): Promise { +function getRatings(hashPrefix: VideoIDHash, service: Service): Promise { const fetchFromDB = () => db .prepare( "all", @@ -74,9 +69,7 @@ async function getRatings(hashPrefix: VideoIDHash, service: Service): Promise; - if (hashPrefix.length === 4) { - return await QueryCacher.get(fetchFromDB, ratingHashKey(hashPrefix, service)); - } - - return fetchFromDB(); + return (hashPrefix.length === 4) + ? QueryCacher.get(fetchFromDB, ratingHashKey(hashPrefix, service)) + : fetchFromDB(); } \ No newline at end of file diff --git a/src/routes/ratings/postClearCache.ts b/src/routes/ratings/postClearCache.ts new file mode 100644 index 0000000..ed3d5d5 --- /dev/null +++ b/src/routes/ratings/postClearCache.ts @@ -0,0 +1,52 @@ +import { Logger } from "../../utils/logger"; +import { HashedUserID, UserID } from "../../types/user.model"; +import { getHash } from "../../utils/getHash"; +import { Request, Response } from "express"; +import { Service, VideoID } from "../../types/segments.model"; +import { QueryCacher } from "../../utils/queryCacher"; +import { isUserVIP } from "../../utils/isUserVIP"; +import { VideoIDHash } from "../../types/segments.model"; +import { getService } from "../..//utils/getService"; + +export async function postClearCache(req: Request, res: Response): Promise { + const videoID = req.query.videoID as VideoID; + const userID = req.query.userID as UserID; + const service = getService(req.query.service as Service); + + const invalidFields = []; + if (typeof videoID !== "string") { + invalidFields.push("videoID"); + } + if (typeof userID !== "string") { + invalidFields.push("userID"); + } + + if (invalidFields.length !== 0) { + // invalid request + const fields = invalidFields.reduce((p, c, i) => p + (i !== 0 ? ", " : "") + c, ""); + return res.status(400).send(`No valid ${fields} field(s) provided`); + } + + // hash the userID as early as possible + const hashedUserID: HashedUserID = getHash(userID); + // hash videoID + const hashedVideoID: VideoIDHash = getHash(videoID, 1); + + // Ensure user is a VIP + if (!(await isUserVIP(hashedUserID))){ + Logger.warn(`Permission violation: User ${hashedUserID} attempted to clear cache for video ${videoID}.`); + return res.status(403).json({ "message": "Not a VIP" }); + } + + try { + QueryCacher.clearRatingCache({ + hashedVideoID, + service + }); + return res.status(200).json({ + message: `Cache cleared on video ${videoID}` + }); + } catch(err) { + return res.sendStatus(500); + } +} diff --git a/src/routes/ratings/postRating.ts b/src/routes/ratings/postRating.ts index 7e73903..f5424b1 100644 --- a/src/routes/ratings/postRating.ts +++ b/src/routes/ratings/postRating.ts @@ -8,6 +8,7 @@ import { getIP } from "../../utils/getIP"; import { getService } from "../../utils/getService"; import { RatingType, RatingTypes } from "../../types/ratings.model"; import { config } from "../../config"; +import { QueryCacher } from "../../utils/queryCacher"; export async function postRating(req: Request, res: Response): Promise { const privateUserID = req.body.userID as UserID; @@ -34,26 +35,26 @@ export async function postRating(req: Request, res: Response): Promise // Undo the vote await db.prepare("run", `UPDATE "ratings" SET "count" = "count" - 1 WHERE "videoID" = ? AND "service" = ? AND type = ?`, [videoID, service, type]); await privateDB.prepare("run", `DELETE FROM "ratings" WHERE "videoID" = ? AND "service" = ? AND "type" = ? AND "userID" = ?`, [videoID, service, type, hashedUserID]); - - return res.sendStatus(200); } else if (existingVote.count === 0 && enabled) { // Make sure there hasn't been another vote from this IP const existingIPVote = (await privateDB.prepare("get", `SELECT count(*) as "count" FROM "ratings" WHERE "videoID" = ? AND "service" = ? AND "type" = ? AND "hashedIP" = ?`, [videoID, service, type, hashedIP])) .count > 0; - if (!existingIPVote) { - // Check if general rating already exists, if so increase it - const rating = await db.prepare("get", `SELECT count(*) as "count" FROM "ratings" WHERE "videoID" = ? AND "service" = ? AND type = ?`, [videoID, service, type]); - if (rating.count > 0) { - await db.prepare("run", `UPDATE "ratings" SET "count" = "count" + 1 WHERE "videoID" = ? AND "service" = ? AND type = ?`, [videoID, service, type]); - } else { - await db.prepare("run", `INSERT INTO "ratings" ("videoID", "service", "type", "count", "hashedVideoID") VALUES (?, ?, ?, 1, ?)`, [videoID, service, type, hashedVideoID]); - } - - // Create entry in privateDB - await privateDB.prepare("run", `INSERT INTO "ratings" ("videoID", "service", "type", "userID", "timeSubmitted", "hashedIP") VALUES (?, ?, ?, ?, ?, ?)`, [videoID, service, type, hashedUserID, Date.now(), hashedIP]); + if (existingIPVote) { // if exisiting vote, exit early instead + return res.sendStatus(200); + } + // Check if general rating already exists, if so increase it + const rating = await db.prepare("get", `SELECT count(*) as "count" FROM "ratings" WHERE "videoID" = ? AND "service" = ? AND type = ?`, [videoID, service, type]); + if (rating.count > 0) { + await db.prepare("run", `UPDATE "ratings" SET "count" = "count" + 1 WHERE "videoID" = ? AND "service" = ? AND type = ?`, [videoID, service, type]); + } else { + await db.prepare("run", `INSERT INTO "ratings" ("videoID", "service", "type", "count", "hashedVideoID") VALUES (?, ?, ?, 1, ?)`, [videoID, service, type, hashedVideoID]); } - } + // Create entry in privateDB + await privateDB.prepare("run", `INSERT INTO "ratings" ("videoID", "service", "type", "userID", "timeSubmitted", "hashedIP") VALUES (?, ?, ?, ?, ?, ?)`, [videoID, service, type, hashedUserID, Date.now(), hashedIP]); + } + // clear rating cache + QueryCacher.clearRatingCache({ hashedVideoID, service }); return res.sendStatus(200); } catch (err) { Logger.error(err as string); diff --git a/src/routes/shadowBanUser.ts b/src/routes/shadowBanUser.ts index 5acd570..fd4f5bc 100644 --- a/src/routes/shadowBanUser.ts +++ b/src/routes/shadowBanUser.ts @@ -68,7 +68,7 @@ export async function shadowBanUser(req: Request, res: Response): Promise `'${c}'`).join(",")})`, [UUID])) .forEach((videoInfo: {category: Category, videoID: VideoID, hashedVideoID: VideoIDHash, service: Service, userID: UserID}) => { - QueryCacher.clearVideoCache(videoInfo); + QueryCacher.clearSegmentCache(videoInfo); } ); @@ -125,6 +125,6 @@ async function unHideSubmissions(categories: string[], userID: UserID) { // clear cache for all old videos (await db.prepare("all", `SELECT "videoID", "hashedVideoID", "service", "votes", "views" FROM "sponsorTimes" WHERE "userID" = ?`, [userID])) .forEach((videoInfo: { category: Category; videoID: VideoID; hashedVideoID: VideoIDHash; service: Service; userID: UserID; }) => { - QueryCacher.clearVideoCache(videoInfo); + QueryCacher.clearSegmentCache(videoInfo); }); } diff --git a/src/routes/voteOnSponsorTime.ts b/src/routes/voteOnSponsorTime.ts index 2fa7272..f713caa 100644 --- a/src/routes/voteOnSponsorTime.ts +++ b/src/routes/voteOnSponsorTime.ts @@ -267,7 +267,7 @@ async function categoryVote(UUID: SegmentUUID, userID: UserID, isVIP: boolean, i } } - QueryCacher.clearVideoCache(videoInfo); + QueryCacher.clearSegmentCache(videoInfo); return res.sendStatus(finalResponse.finalStatus); } @@ -473,7 +473,7 @@ export async function voteOnSponsorTime(req: Request, res: Response): Promise(fetchFromDB: () => Promise, key: string): Promise { return data; } -function clearVideoCache(videoInfo: { videoID: VideoID; hashedVideoID: VideoIDHash; service: Service; userID?: UserID; }): void { +function clearSegmentCache(videoInfo: { videoID: VideoID; hashedVideoID: VideoIDHash; service: Service; userID?: UserID; }): void { if (videoInfo) { redis.delAsync(skipSegmentsKey(videoInfo.videoID, videoInfo.service)); redis.delAsync(skipSegmentsHashKey(videoInfo.hashedVideoID, videoInfo.service)); @@ -30,7 +30,14 @@ function clearVideoCache(videoInfo: { videoID: VideoID; hashedVideoID: VideoIDHa } } +function clearRatingCache(videoInfo: { hashedVideoID: VideoIDHash; service: Service;}): void { + if (videoInfo) { + redis.delAsync(ratingHashKey(videoInfo.hashedVideoID, videoInfo.service)); + } +} + export const QueryCacher = { get, - clearVideoCache + clearSegmentCache, + clearRatingCache }; \ No newline at end of file diff --git a/test/cases/ratings/getRating.ts b/test/cases/ratings/getRating.ts index 876ec47..ee82f23 100644 --- a/test/cases/ratings/getRating.ts +++ b/test/cases/ratings/getRating.ts @@ -8,15 +8,19 @@ import { partialDeepEquals } from "../../utils/partialDeepEquals"; const endpoint = "/api/ratings/rate/"; const getRating = (hash: string, params?: unknown): Promise => client.get(endpoint + hash, { params }); +const videoOneID = "some-likes-and-dislikes"; +const videoOneIDHash = getHash(videoOneID, 1); +const videoOnePartialHash = videoOneIDHash.substr(0, 4); + describe("getRating", () => { before(async () => { const insertUserNameQuery = 'INSERT INTO "ratings" ("videoID", "service", "type", "count", "hashedVideoID") VALUES (?, ?, ?, ?, ?)'; - await db.prepare("run", insertUserNameQuery, ["some-likes-and-dislikes", "YouTube", 0, 5, getHash("some-likes-and-dislikes", 1)]); //b3f0 - await db.prepare("run", insertUserNameQuery, ["some-likes-and-dislikes", "YouTube", 1, 10, getHash("some-likes-and-dislikes", 1)]); + await db.prepare("run", insertUserNameQuery, [videoOneID, "YouTube", 0, 5, videoOneIDHash]); + await db.prepare("run", insertUserNameQuery, [videoOneID, "YouTube", 1, 10, videoOneIDHash]); }); it("Should be able to get dislikes and likes by default", (done) => { - getRating("b3f0") + getRating(videoOnePartialHash) .then(res => { assert.strictEqual(res.status, 200); const expected = [{ @@ -33,7 +37,7 @@ describe("getRating", () => { }); it("Should be able to filter for only dislikes", (done) => { - getRating("b3f0", { type: 0 }) + getRating(videoOnePartialHash, { type: 0 }) .then(res => { assert.strictEqual(res.status, 200); const expected = [{ @@ -46,4 +50,31 @@ describe("getRating", () => { }) .catch(err => done(err)); }); + + it("Should return 400 for invalid hash", (done) => { + getRating("a") + .then(res => { + assert.strictEqual(res.status, 400); + done(); + }) + .catch(err => done(err)); + }); + + it("Should return 404 for nonexitent type", (done) => { + getRating(videoOnePartialHash, { type: 100 }) + .then(res => { + assert.strictEqual(res.status, 404); + done(); + }) + .catch(err => done(err)); + }); + + it("Should return 404 for nonexistent videoID", (done) => { + getRating("aaaa") + .then(res => { + assert.strictEqual(res.status, 404); + done(); + }) + .catch(err => done(err)); + }); }); \ No newline at end of file diff --git a/test/cases/ratings/postClearCache.ts b/test/cases/ratings/postClearCache.ts new file mode 100644 index 0000000..1b2b69a --- /dev/null +++ b/test/cases/ratings/postClearCache.ts @@ -0,0 +1,51 @@ +import { db } from "../../../src/databases/databases"; +import { getHash } from "../../../src/utils/getHash"; +import assert from "assert"; +import { client } from "../../utils/httpClient"; + +const VIPUser = "clearCacheVIP"; +const regularUser = "regular-user"; +const endpoint = "/api/ratings/clearCache"; +const postClearCache = (userID: string, videoID: string) => client({ method: "post", url: endpoint, params: { userID, videoID } }); + +describe("ratings postClearCache", () => { + before(async () => { + await db.prepare("run", `INSERT INTO "vipUsers" ("userID") VALUES ('${getHash(VIPUser)}')`); + }); + + it("Should be able to clear cache amy video", (done) => { + postClearCache(VIPUser, "dne-video") + .then(res => { + assert.strictEqual(res.status, 200); + done(); + }) + .catch(err => done(err)); + }); + + it("Should get 403 as non-vip", (done) => { + postClearCache(regularUser, "clear-test") + .then(res => { + assert.strictEqual(res.status, 403); + done(); + }) + .catch(err => done(err)); + }); + + it("Should give 400 with missing videoID", (done) => { + client.post(endpoint, { params: { userID: VIPUser } }) + .then(res => { + assert.strictEqual(res.status, 400); + done(); + }) + .catch(err => done(err)); + }); + + it("Should give 400 with missing userID", (done) => { + client.post(endpoint, { params: { videoID: "clear-test" } }) + .then(res => { + assert.strictEqual(res.status, 400); + done(); + }) + .catch(err => done(err)); + }); +}); diff --git a/test/cases/ratings/postRating.ts b/test/cases/ratings/postRating.ts index 9ff7ebd..be63f60 100644 --- a/test/cases/ratings/postRating.ts +++ b/test/cases/ratings/postRating.ts @@ -9,37 +9,43 @@ const endpoint = "/api/ratings/rate/"; const postRating = (body: unknown): Promise => client.post(endpoint, body); const queryDatabase = (videoID: string) => db.prepare("all", `SELECT * FROM "ratings" WHERE "videoID" = ?`, [videoID]); +const videoIDOne = "normal-video"; +const videoIDTwo = "multiple-rates"; +const ratingUserID = "rating-testman"; + describe("postRating", () => { before(async () => { const insertUserNameQuery = 'INSERT INTO "ratings" ("videoID", "service", "type", "count", "hashedVideoID") VALUES (?, ?, ?, ?, ?)'; - await db.prepare("run", insertUserNameQuery, ["multiple-rates", "YouTube", 0, 3, getHash("multiple-rates", 1)]); + await db.prepare("run", insertUserNameQuery, [videoIDTwo, "YouTube", 0, 3, getHash(videoIDTwo, 1)]); }); it("Should be able to vote on a video", (done) => { + const videoID = videoIDOne; postRating({ - userID: "rating-testman", - videoID: "normal-video", + userID: ratingUserID, + videoID, type: 0 }) .then(async res => { assert.strictEqual(res.status, 200); const expected = [{ - hashedVideoID: getHash("normal-video", 1), - videoID: "normal-video", + hashedVideoID: getHash(videoID, 1), + videoID, type: 0, count: 1, service: "YouTube" }]; - assert.ok(partialDeepEquals(await queryDatabase("normal-video"), expected)); + assert.ok(partialDeepEquals(await queryDatabase(videoID), expected)); done(); }) .catch(err => done(err)); }); it("Should be able to undo a vote on a video", (done) => { + const videoID = videoIDOne; postRating({ - userID: "rating-testman", - videoID: "normal-video", + userID: ratingUserID, + videoID, type: 0, enabled: false }) @@ -49,16 +55,17 @@ describe("postRating", () => { type: 0, count: 0 }]; - assert.ok(partialDeepEquals(await queryDatabase("normal-video"), expected)); + assert.ok(partialDeepEquals(await queryDatabase(videoID), expected)); done(); }) .catch(err => done(err)); }); it("Should be able to vote after someone else on a video", (done) => { + const videoID = videoIDTwo; postRating({ - userID: "rating-testman", - videoID: "multiple-rates", + userID: ratingUserID, + videoID, type: 0 }) .then(async res => { @@ -67,16 +74,17 @@ describe("postRating", () => { type: 0, count: 4 }]; - assert.ok(partialDeepEquals(await queryDatabase("multiple-rates"), expected)); + assert.ok(partialDeepEquals(await queryDatabase(videoID), expected)); done(); }) .catch(err => done(err)); }); it("Should be able to vote a different type than existing votes on a video", (done) => { + const videoID = videoIDTwo; postRating({ - userID: "rating-testman", - videoID: "multiple-rates", + userID: ratingUserID, + videoID, type: 1 }) .then(async res => { @@ -88,7 +96,21 @@ describe("postRating", () => { type: 1, count: 1 }]; - assert.ok(partialDeepEquals(await queryDatabase("multiple-rates"), expected)); + assert.ok(partialDeepEquals(await queryDatabase(videoID), expected)); + done(); + }) + .catch(err => done(err)); + }); + + it("Should not be able to vote with nonexistent type", (done) => { + const videoID = videoIDOne; + postRating({ + userID: ratingUserID, + videoID, + type: 100 + }) + .then(res => { + assert.strictEqual(res.status, 400); done(); }) .catch(err => done(err));