diff --git a/src/routes/postSkipSegments.ts b/src/routes/postSkipSegments.ts index aaf8eda..47221ac 100644 --- a/src/routes/postSkipSegments.ts +++ b/src/routes/postSkipSegments.ts @@ -22,6 +22,7 @@ import axios from "axios"; import { vote } from "./voteOnSponsorTime"; import { canSubmit } from "../utils/permissions"; import { getVideoDetails, videoDetails } from "../utils/getVideoDetails"; +import * as youtubeID from "../utils/youtubeID"; type CheckResult = { pass: boolean, @@ -185,15 +186,23 @@ async function checkUserActiveWarning(userID: string): Promise { } async function checkInvalidFields(videoID: VideoID, userID: UserID, hashedUserID: HashedUserID - , segments: IncomingSegment[], videoDurationParam: number, userAgent: string): Promise { + , segments: IncomingSegment[], videoDurationParam: number, userAgent: string, service: Service): Promise { const invalidFields = []; const errors = []; if (typeof videoID !== "string" || videoID?.length == 0) { invalidFields.push("videoID"); } - if (typeof userID !== "string" || userID?.length < 30) { + if (service === Service.YouTube && config.mode !== "test") { + const sanitizedVideoID = youtubeID.validate(videoID) ? videoID : youtubeID.sanitize(videoID); + if (!youtubeID.validate(sanitizedVideoID)) { + invalidFields.push("videoID"); + errors.push("YouTube videoID could not be extracted"); + } + } + const minLength = config.minUserIDLength; + if (typeof userID !== "string" || userID?.length < minLength) { invalidFields.push("userID"); - if (userID?.length < 30) errors.push(`userID must be at least 30 characters long`); + if (userID?.length < minLength) errors.push(`userID must be at least ${minLength} characters long`); } if (!Array.isArray(segments) || segments.length == 0) { invalidFields.push("segments"); @@ -484,7 +493,7 @@ export async function postSkipSegments(req: Request, res: Response): Promise?(?:\\s|$)`); +const negateIdRegex = new RegExp(/(?:[^0-9A-Za-z_-]*?)/); +const looseEndsRegex = new RegExp(`${negateIdRegex.source}${idRegex.source}${negateIdRegex.source}`); + +export const validate = (id: string): boolean => exclusiveIdegex.test(id); + +export const sanitize = (id: string): VideoID | null => { + // first decode URI + id = decodeURIComponent(id); + // strict matching + const strictMatch = id.match(exclusiveIdegex)?.[1]; + const urlMatch = id.match(urlRegex)?.[1]; + // return match, if not negative, return looseMatch + const looseMatch = id.match(looseEndsRegex)?.[1]; + return strictMatch ? (strictMatch as VideoID) + : negativeRegex.test(id) ? null + : urlMatch ? (urlMatch as VideoID) + : looseMatch ? (looseMatch as VideoID) + : null; +}; \ No newline at end of file diff --git a/test.json b/test.json index 16ace5c..fef8052 100644 --- a/test.json +++ b/test.json @@ -64,5 +64,6 @@ "clientSecret": "testClientSecret", "redirectUri": "http://127.0.0.1/fake/callback" }, - "minReputationToSubmitFiller": -1 + "minReputationToSubmitFiller": -1, + "minUserIDLength": 0 } diff --git a/test/cases/environment.ts b/test/cases/environment.ts new file mode 100644 index 0000000..a0a6d88 --- /dev/null +++ b/test/cases/environment.ts @@ -0,0 +1,12 @@ +import assert from "assert"; +import { config } from "../../src/config"; + +describe("environment", () => { + it("minUserIDLength should be < 10", () => { + assert(config.minUserIDLength < 10); + }); + it("nodeJS major version should be >= 16", () => { + const [major] = process.versions.node.split(".").map(i => parseInt(i)); + assert(major >= 16); + }); +}); diff --git a/test/cases/validateVideoIDs.ts b/test/cases/validateVideoIDs.ts new file mode 100644 index 0000000..dc1c62d --- /dev/null +++ b/test/cases/validateVideoIDs.ts @@ -0,0 +1,125 @@ +import assert from "assert"; +import { client } from "../utils/httpClient"; +import { config } from "../../src/config"; +import sinon from "sinon"; +import { sanitize } from "../../src/utils/youtubeID"; + +// videoID array +const badVideoIDs = [ + ["null", "< 11"], + ["dQw4w9WgXc?", "invalid characters"], + ["https://www.youtube.com/clip/UgkxeLPGsmKnMdm46DGml_0aa0aaAAAAA00a", "clip URL"], + ["https://youtube.com/channel/UCaAa00aaaAA0a0a0AaaAAAA", "channel ID (UC)"], + ["https://www.youtube.com/@LinusTechTips", "channel @username"], + ["https://www.youtube.com/@GamersNexus", "channel @username"], + ["https://www.youtube.com/c/LinusTechTips", "custom channel /c/"], + ["https://www.youtube.com/c/GamersNexus", "custom channel /c/"], + ["https://www.youtube.com/", "home/ page URL"], + ["03224876b002487796379942f199bc22ffac46157ad2488119bccc7b03c55430","UUID"], + ["https://www.youtube.com/watch?v=dQw4w9WgXcQ&t=16s#requiredSegment=03224876b002487796379942f199bc22ffac46157ad2488119bccc7b03c55430", "full #requiredSegments uuid"], + ["","empty videoID"] + +]; +const goodVideoIDs = [ + ["dQw4w9WgXcQ", "standalone videoID"], + ["https://www.youtube.com/watch?v=dQw4w9WgXcQ", "?watch link"], + ["http://www.youtube.com/watch?v=dQw4w9WgXcQ", "http link"], + ["www.youtube.com/watch?v=dQw4w9WgXcQ", "no protocol link"], + ["https://www.youtube.com/watch?v=dQw4w9WgXcQ?t=2", "trailing &t parameter"], + ["https://youtu.be/dQw4w9WgXcQ","youtu.be"], + ["youtu.be/dQw4w9WgXcQ","no protocol youtu.be"], + ["https://www.youtube.com/watch?v=dQw4w9WgXcQ&t=16s#requiredSegment=00000000000","#requiredsegment link"], + ["https://www.youtube.com/embed/dQw4w9WgXcQ?wmode=transparent&rel=0&autohide=1&showinfo=1&fs=1&enablejsapi=0&theme=light", "long embedded link"], + ["http://m.youtube.com/watch?v=dQw4w9WgXcQ&app=m&persist_app=1", "force persist desktop"], + ["http://m.youtube.com/watch?v=dQw4w9WgXcQ", "mobile"], + ["https://www.youtube.com/watch?v=dQw4w9WgXcQ&list=PL8mG-AaA0aAa0AAa0A0A-aAaaA00aaAa0","/watch&list"], + ["https://www.youtube.com/embed/dQw4w9WgXcQ?list=PL8mG-AaA0aAa0AAa0A0A-aAaaA00aaAa0","/embed/video"], + ["dQw4w9WgXcQ\n", "escaped newline"], + ["dQw4w9WgXcQ\t", "escaped tab"], + ["%20dQw4w9WgXcQ%20%20%20", "urlencoded"], + ["https://sb.ltn.fi/video/dQw4w9WgXcQ/","sbltnfi link"], + ["https://www.youtube.com/watch?v=dQw4w9WgXcQ#t=0m10s", "anchor as t parameter"], +]; +const edgeVideoIDs = [ + ["https://www.youtube.com/embed/videoseries?list=PL8mG-Aaa0aAa1AAa0A0A-a0aaA00aaAa0", "/videoseries"], + ["https://www.youtube.com/embed/playlist?list=PL8mG-Aaa0aAa1AAa0A0A-a0aaA00aaAa0", "/playlist"], + ["PL8mG-Aaa0aAa1AAa0A0A-a0aaA00aaAa0", "playlist ID"], + ["UgkxeLPGsmKnMdm46DGml_0aa0aaAAAAA00a","clip ID"], + ["https://www.youtube.com/GamersNexus", "channel custom URL"], + ["https://www.youtube.com/LinusTechTips", "channel custom URL"], +]; +const targetVideoID = "dQw4w9WgXcQ"; + +// tests +describe("YouTube VideoID validation - failing tests", () => { + for (const testCase of badVideoIDs) { + it(`Should error on invalid videoID - ${testCase[1]}`, () => { + assert.equal(sanitize(testCase[0]), null); + }); + } +}); +describe("YouTube VideoID validation - passing tests", () => { + for (const testCase of goodVideoIDs) { + it(`Should be able to sanitize good videoID - ${testCase[1]}`, () => { + assert.equal(sanitize(testCase[0]), targetVideoID); + }); + } +}); +describe("YouTube VideoID validation - edge cases tests", () => { + for (const testCase of edgeVideoIDs) { + it(`edge cases produce bad results - ${testCase[1]}`, () => { + assert.ok(sanitize(testCase[0])); + }); + } +}); + +// stubs +const mode = "production"; +let stub: sinon.SinonStub; + +// constants +const endpoint = "/api/skipSegments"; +const userID = "postVideoID_user1"; +const expectedError = `No valid videoID. YouTube videoID could not be extracted`; + + +// helper functions +const postSkipSegments = (videoID: string) => client({ + method: "POST", + url: endpoint, + params: { + videoID, + startTime: Math.random(), + endTime: 10, + userID, + service: "YouTube", + category: "sponsor" + } +}); + +describe("VideoID Validation - postSkipSegments", () => { + before(() => stub = sinon.stub(config, "mode").value(mode)); + after(() => stub.restore()); + + it("Should return production mode if stub worked", (done) => { + assert.strictEqual(config.mode, mode); + done(); + }); + + it(`Should return 400 for invalid videoID`, (done) => { + postSkipSegments("123456").then(res => { + assert.strictEqual(res.status, 400); + assert.strictEqual(res.data, expectedError); + done(); + }) + .catch(err => done(err)); + }); + + it(`Should return 200 for valid videoID`, (done) => { + postSkipSegments("dQw4w9WgXcQ").then(res => { + assert.strictEqual(res.status, 200); + done(); + }) + .catch(err => done(err)); + }); +}); \ No newline at end of file