Skip to content

Commit 7c2feb8

Browse files
committed
videoID validation and userID min length
1 parent b591b71 commit 7c2feb8

File tree

5 files changed

+178
-5
lines changed

5 files changed

+178
-5
lines changed

src/routes/postSkipSegments.ts

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import axios from "axios";
2222
import { vote } from "./voteOnSponsorTime";
2323
import { canSubmit } from "../utils/permissions";
2424
import { getVideoDetails, videoDetails } from "../utils/getVideoDetails";
25+
import * as youtubeID from "../utils/youtubeID";
2526

2627
type CheckResult = {
2728
pass: boolean,
@@ -185,15 +186,23 @@ async function checkUserActiveWarning(userID: string): Promise<CheckResult> {
185186
}
186187

187188
async function checkInvalidFields(videoID: VideoID, userID: UserID, hashedUserID: HashedUserID
188-
, segments: IncomingSegment[], videoDurationParam: number, userAgent: string): Promise<CheckResult> {
189+
, segments: IncomingSegment[], videoDurationParam: number, userAgent: string, service: Service): Promise<CheckResult> {
189190
const invalidFields = [];
190191
const errors = [];
191192
if (typeof videoID !== "string" || videoID?.length == 0) {
192193
invalidFields.push("videoID");
193194
}
194-
if (typeof userID !== "string" || userID?.length < 30) {
195+
if (service === Service.YouTube && config.mode !== "test") {
196+
const sanitizedVideoID = youtubeID.validate(videoID) ? videoID : youtubeID.sanitize(videoID);
197+
if (!youtubeID.validate(sanitizedVideoID)) {
198+
invalidFields.push("videoID");
199+
errors.push("YouTube videoID could not be extracted");
200+
}
201+
}
202+
const minLength = config.minUserIDLength;
203+
if (typeof userID !== "string" || userID?.length < minLength) {
195204
invalidFields.push("userID");
196-
if (userID?.length < 30) errors.push(`userID must be at least 30 characters long`);
205+
if (userID?.length < minLength) errors.push(`userID must be at least ${minLength} characters long`);
197206
}
198207
if (!Array.isArray(segments) || segments.length == 0) {
199208
invalidFields.push("segments");
@@ -484,7 +493,7 @@ export async function postSkipSegments(req: Request, res: Response): Promise<Res
484493
//hash the userID
485494
const userID = await getHashCache(paramUserID || "");
486495

487-
const invalidCheckResult = await checkInvalidFields(videoID, paramUserID, userID, segments, videoDurationParam, userAgent);
496+
const invalidCheckResult = await checkInvalidFields(videoID, paramUserID, userID, segments, videoDurationParam, userAgent, service);
488497
if (!invalidCheckResult.pass) {
489498
return res.status(invalidCheckResult.errorCode).send(invalidCheckResult.errorMessage);
490499
}

src/utils/youtubeID.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
import { VideoID } from "../types/segments.model";
2+
3+
const idRegex = new RegExp(/([0-9A-Za-z_-]{11})/); // group to always be index 1
4+
const exclusiveIdegex = new RegExp(`^${idRegex.source}$`);
5+
// match /c/, /channel/, /@channel, full UUIDs
6+
const negativeRegex = new RegExp(/(\/(channel|c)\/.+)|(\/@.+)|([a-f0-9]{64,65})|(youtube\.com\/clip\/)/);
7+
const urlRegex = new RegExp(`(?:v=|/|youtu.be/)${idRegex.source}(?:|/|[?&]t=\\d+s?)>?(?:\\s|$)`);
8+
const negateIdRegex = new RegExp(/(?:[^0-9A-Za-z_-]*?)/);
9+
const looseEndsRegex = new RegExp(`${negateIdRegex.source}${idRegex.source}${negateIdRegex.source}`);
10+
11+
export const validate = (id: string): boolean => exclusiveIdegex.test(id);
12+
13+
export const sanitize = (id: string): VideoID | null => {
14+
// first decode URI
15+
id = decodeURIComponent(id);
16+
// strict matching
17+
const strictMatch = id.match(exclusiveIdegex)?.[1];
18+
const urlMatch = id.match(urlRegex)?.[1];
19+
// return match, if not negative, return looseMatch
20+
const looseMatch = id.match(looseEndsRegex)?.[1];
21+
return strictMatch ? (strictMatch as VideoID)
22+
: negativeRegex.test(id) ? null
23+
: urlMatch ? (urlMatch as VideoID)
24+
: looseMatch ? (looseMatch as VideoID)
25+
: null;
26+
};

test.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -64,5 +64,6 @@
6464
"clientSecret": "testClientSecret",
6565
"redirectUri": "http://127.0.0.1/fake/callback"
6666
},
67-
"minReputationToSubmitFiller": -1
67+
"minReputationToSubmitFiller": -1,
68+
"minUserIDLength": 0
6869
}

test/cases/environment.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
import assert from "assert";
2+
import { config } from "../../src/config";
3+
4+
describe("environment", () => {
5+
it("minUserIDLength should be < 10", () => {
6+
assert(config.minUserIDLength < 10);
7+
});
8+
it("nodeJS major version should be >= 16", () => {
9+
const [major] = process.versions.node.split(".").map(i => parseInt(i));
10+
assert(major >= 16);
11+
});
12+
});

test/cases/validateVideoIDs.ts

Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
1+
import assert from "assert";
2+
import { client } from "../utils/httpClient";
3+
import { config } from "../../src/config";
4+
import sinon from "sinon";
5+
import { sanitize } from "../../src/utils/youtubeID";
6+
7+
// videoID array
8+
const badVideoIDs = [
9+
["null", "< 11"],
10+
["dQw4w9WgXc?", "invalid characters"],
11+
["https://www.youtube.com/clip/UgkxeLPGsmKnMdm46DGml_0aa0aaAAAAA00a", "clip URL"],
12+
["https://youtube.com/channel/UCaAa00aaaAA0a0a0AaaAAAA", "channel ID (UC)"],
13+
["https://www.youtube.com/@LinusTechTips", "channel @username"],
14+
["https://www.youtube.com/@GamersNexus", "channel @username"],
15+
["https://www.youtube.com/c/LinusTechTips", "custom channel /c/"],
16+
["https://www.youtube.com/c/GamersNexus", "custom channel /c/"],
17+
["https://www.youtube.com/", "home/ page URL"],
18+
["03224876b002487796379942f199bc22ffac46157ad2488119bccc7b03c55430","UUID"],
19+
["https://www.youtube.com/watch?v=dQw4w9WgXcQ&t=16s#requiredSegment=03224876b002487796379942f199bc22ffac46157ad2488119bccc7b03c55430", "full #requiredSegments uuid"],
20+
["","empty videoID"]
21+
22+
];
23+
const goodVideoIDs = [
24+
["dQw4w9WgXcQ", "standalone videoID"],
25+
["https://www.youtube.com/watch?v=dQw4w9WgXcQ", "?watch link"],
26+
["http://www.youtube.com/watch?v=dQw4w9WgXcQ", "http link"],
27+
["www.youtube.com/watch?v=dQw4w9WgXcQ", "no protocol link"],
28+
["https://www.youtube.com/watch?v=dQw4w9WgXcQ?t=2", "trailing &t parameter"],
29+
["https://youtu.be/dQw4w9WgXcQ","youtu.be"],
30+
["youtu.be/dQw4w9WgXcQ","no protocol youtu.be"],
31+
["https://www.youtube.com/watch?v=dQw4w9WgXcQ&t=16s#requiredSegment=00000000000","#requiredsegment link"],
32+
["https://www.youtube.com/embed/dQw4w9WgXcQ?wmode=transparent&rel=0&autohide=1&showinfo=1&fs=1&enablejsapi=0&theme=light", "long embedded link"],
33+
["http://m.youtube.com/watch?v=dQw4w9WgXcQ&app=m&persist_app=1", "force persist desktop"],
34+
["http://m.youtube.com/watch?v=dQw4w9WgXcQ", "mobile"],
35+
["https://www.youtube.com/watch?v=dQw4w9WgXcQ&list=PL8mG-AaA0aAa0AAa0A0A-aAaaA00aaAa0","/watch&list"],
36+
["https://www.youtube.com/embed/dQw4w9WgXcQ?list=PL8mG-AaA0aAa0AAa0A0A-aAaaA00aaAa0","/embed/video"],
37+
["dQw4w9WgXcQ\n", "escaped newline"],
38+
["dQw4w9WgXcQ\t", "escaped tab"],
39+
["%20dQw4w9WgXcQ%20%20%20", "urlencoded"],
40+
["https://sb.ltn.fi/video/dQw4w9WgXcQ/","sbltnfi link"],
41+
["https://www.youtube.com/watch?v=dQw4w9WgXcQ#t=0m10s", "anchor as t parameter"],
42+
];
43+
const edgeVideoIDs = [
44+
["https://www.youtube.com/embed/videoseries?list=PL8mG-Aaa0aAa1AAa0A0A-a0aaA00aaAa0", "/videoseries"],
45+
["https://www.youtube.com/embed/playlist?list=PL8mG-Aaa0aAa1AAa0A0A-a0aaA00aaAa0", "/playlist"],
46+
["PL8mG-Aaa0aAa1AAa0A0A-a0aaA00aaAa0", "playlist ID"],
47+
["UgkxeLPGsmKnMdm46DGml_0aa0aaAAAAA00a","clip ID"],
48+
["https://www.youtube.com/GamersNexus", "channel custom URL"],
49+
["https://www.youtube.com/LinusTechTips", "channel custom URL"],
50+
];
51+
const targetVideoID = "dQw4w9WgXcQ";
52+
53+
// tests
54+
describe("YouTube VideoID validation - failing tests", () => {
55+
for (const testCase of badVideoIDs) {
56+
it(`Should error on invalid videoID - ${testCase[1]}`, () => {
57+
assert.equal(sanitize(testCase[0]), null);
58+
});
59+
}
60+
});
61+
describe("YouTube VideoID validation - passing tests", () => {
62+
for (const testCase of goodVideoIDs) {
63+
it(`Should be able to sanitize good videoID - ${testCase[1]}`, () => {
64+
assert.equal(sanitize(testCase[0]), targetVideoID);
65+
});
66+
}
67+
});
68+
describe("YouTube VideoID validation - edge cases tests", () => {
69+
for (const testCase of edgeVideoIDs) {
70+
it(`edge cases produce bad results - ${testCase[1]}`, () => {
71+
assert.ok(sanitize(testCase[0]));
72+
});
73+
}
74+
});
75+
76+
// stubs
77+
const mode = "production";
78+
let stub: sinon.SinonStub;
79+
80+
// constants
81+
const endpoint = "/api/skipSegments";
82+
const userID = "postVideoID_user1";
83+
const expectedError = `No valid videoID. YouTube videoID could not be extracted`;
84+
85+
86+
// helper functions
87+
const postSkipSegments = (videoID: string) => client({
88+
method: "POST",
89+
url: endpoint,
90+
params: {
91+
videoID,
92+
startTime: Math.random(),
93+
endTime: 10,
94+
userID,
95+
service: "YouTube",
96+
category: "sponsor"
97+
}
98+
});
99+
100+
describe("VideoID Validation - postSkipSegments", () => {
101+
before(() => stub = sinon.stub(config, "mode").value(mode));
102+
after(() => stub.restore());
103+
104+
it("Should return production mode if stub worked", (done) => {
105+
assert.strictEqual(config.mode, mode);
106+
done();
107+
});
108+
109+
it(`Should return 400 for invalid videoID`, (done) => {
110+
postSkipSegments("123456").then(res => {
111+
assert.strictEqual(res.status, 400);
112+
assert.strictEqual(res.data, expectedError);
113+
done();
114+
})
115+
.catch(err => done(err));
116+
});
117+
118+
it(`Should return 200 for valid videoID`, (done) => {
119+
postSkipSegments("dQw4w9WgXcQ").then(res => {
120+
assert.strictEqual(res.status, 200);
121+
done();
122+
})
123+
.catch(err => done(err));
124+
});
125+
});

0 commit comments

Comments
 (0)