diff --git a/src/app.ts b/src/app.ts index 44a59f6..589f352 100644 --- a/src/app.ts +++ b/src/app.ts @@ -29,6 +29,7 @@ import {apiCspMiddleware} from './middleware/apiCsp'; import {rateLimitMiddleware} from './middleware/requestRateLimit'; import dumpDatabase, {redirectLink} from './routes/dumpDatabase'; import {endpoint as getSegmentInfo} from './routes/getSegmentInfo'; +import {postClearCache} from './routes/postClearCache'; export function createServer(callback: () => void) { // Create a service (the app object is just a callback). @@ -136,6 +137,9 @@ function setupRoutes(app: Express) { //get segment info app.get('/api/segmentInfo', getSegmentInfo); + //clear cache as VIP + app.post('/api/clearCache', postClearCache) + if (config.postgres) { app.get('/database', (req, res) => dumpDatabase(req, res, true)); app.get('/database.json', (req, res) => dumpDatabase(req, res, false)); diff --git a/src/routes/postClearCache.ts b/src/routes/postClearCache.ts new file mode 100644 index 0000000..c3bc1ca --- /dev/null +++ b/src/routes/postClearCache.ts @@ -0,0 +1,55 @@ +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"; + +export async function postClearCache(req: Request, res: Response) { + const videoID = req.query.videoID as VideoID; + let userID = req.query.userID as UserID; + const service = req.query.service as Service ?? Service.YouTube; + + 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, ''); + res.status(400).send(`No valid ${fields} field(s) provided`); + return false; + } + + // 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 + "."); + res.status(403).json({"message": "Not a VIP"}); + return false; + } + + try { + QueryCacher.clearVideoCache({ + videoID, + hashedVideoID, + service + }); + res.status(200).json({ + message: "Cache cleared on video " + videoID + }); + } catch(err) { + res.status(500).send() + return false; + } +} diff --git a/src/utils/queryCacher.ts b/src/utils/queryCacher.ts index 64cf206..0c4d880 100644 --- a/src/utils/queryCacher.ts +++ b/src/utils/queryCacher.ts @@ -22,15 +22,14 @@ async function get(fetchFromDB: () => Promise, key: string): Promise { return data; } -function clearVideoCache(videoInfo: { videoID: VideoID; hashedVideoID: VideoIDHash; service: Service; userID: UserID; }) { +function clearVideoCache(videoInfo: { videoID: VideoID; hashedVideoID: VideoIDHash; service: Service; userID?: UserID; }) { if (videoInfo) { redis.delAsync(skipSegmentsKey(videoInfo.videoID, videoInfo.service)); redis.delAsync(skipSegmentsHashKey(videoInfo.hashedVideoID, videoInfo.service)); - redis.delAsync(reputationKey(videoInfo.userID)); + if (videoInfo.userID) redis.delAsync(reputationKey(videoInfo.userID)); } } - export const QueryCacher = { get, clearVideoCache diff --git a/test/cases/postClearCache.ts b/test/cases/postClearCache.ts new file mode 100644 index 0000000..ffedaae --- /dev/null +++ b/test/cases/postClearCache.ts @@ -0,0 +1,72 @@ +import fetch from 'node-fetch'; +import {Done, getbaseURL} from '../utils'; +import {db} from '../../src/databases/databases'; +import {getHash} from '../../src/utils/getHash'; + +describe('postClearCache', () => { + before(async () => { + await db.prepare("run", `INSERT INTO "vipUsers" ("userID") VALUES ('` + getHash("clearing-vip") + "')"); + let startOfQuery = 'INSERT INTO "sponsorTimes" ("videoID", "startTime", "endTime", "votes", "UUID", "userID", "timeSubmitted", views, category, "shadowHidden", "hashedVideoID") VALUES'; + await db.prepare("run", startOfQuery + "('clear-test', 0, 1, 2, 'clear-uuid', 'testman', 0, 50, 'sponsor', 0, '" + getHash('clear-test', 1) + "')"); + }); + + it('Should be able to clear cache for existing video', (done: Done) => { + fetch(getbaseURL() + + "/api/clearCache?userID=clearing-vip&videoID=clear-test", { + method: 'POST' + }) + .then(res => { + if (res.status === 200) done(); + else done("Status code was " + res.status); + }) + .catch(err => done(err)); + }); + + it('Should be able to clear cache for nonexistent video', (done: Done) => { + fetch(getbaseURL() + + "/api/clearCache?userID=clearing-vip&videoID=dne-video", { + method: 'POST' + }) + .then(res => { + if (res.status === 200) done(); + else done("Status code was " + res.status); + }) + .catch(err => done(err)); + }); + + it('Should get 403 as non-vip', (done: Done) => { + fetch(getbaseURL() + + "/api/clearCache?userID=regular-user&videoID=clear-tes", { + method: 'POST' + }) + .then(async res => { + if (res.status !== 403) done('non 403 (' + res.status + ')'); + else done(); // pass + }) + .catch(err => done(err)); + }); + + it('Should give 400 with missing videoID', (done: Done) => { + fetch(getbaseURL() + + "/api/clearCache?userID=clearing-vip", { + method: 'POST' + }) + .then(async res => { + if (res.status !== 400) done('non 400 (' + res.status + ')'); + else done(); // pass + }) + .catch(err => done(err)); + }); + + it('Should give 400 with missing userID', (done: Done) => { + fetch(getbaseURL() + + "/api/clearCache?userID=clearing-vip", { + method: 'POST' + }) + .then(async res => { + if (res.status !== 400) done('non 400 (' + res.status + ')'); + else done(); // pass + }) + .catch(err => done(err)); + }); +});