Merge pull request #438 from mchangrh/tempVIP

channel-specific VIP
This commit is contained in:
Ajay Ramachandran
2021-12-31 14:02:05 -05:00
committed by GitHub
11 changed files with 308 additions and 18 deletions

View File

@@ -207,6 +207,7 @@
[categoryVotes](#categoryVotes) [categoryVotes](#categoryVotes)
[sponsorTimes](#sponsorTimes) [sponsorTimes](#sponsorTimes)
[config](#config) [config](#config)
[tempVipLog](#tempVipLog)
### vote ### vote
@@ -270,3 +271,11 @@
| index | field | | index | field |
| -- | :--: | | -- | :--: |
| ratings_videoID | videoID, service, userID, timeSubmitted | | 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 |

View File

@@ -5,7 +5,7 @@ CREATE TABLE IF NOT EXISTS "ratings" (
"service" TEXT NOT NULL default 'YouTube', "service" TEXT NOT NULL default 'YouTube',
"type" INTEGER NOT NULL, "type" INTEGER NOT NULL,
"userID" TEXT NOT NULL, "userID" TEXT NOT NULL,
"timeSubmitted" INTEGER NOT NULL, "timeSubmitted" INTEGER NOT NULL,
"hashedIP" TEXT NOT NULL "hashedIP" TEXT NOT NULL
); );

View File

@@ -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;

View File

@@ -46,6 +46,7 @@ import { getChapterNames } from "./routes/getChapterNames";
import { postRating } from "./routes/ratings/postRating"; import { postRating } from "./routes/ratings/postRating";
import { getRating } from "./routes/ratings/getRating"; import { getRating } from "./routes/ratings/getRating";
import { postClearCache as ratingPostClearCache } from "./routes/ratings/postClearCache"; import { postClearCache as ratingPostClearCache } from "./routes/ratings/postClearCache";
import { addUserAsTempVIP } from "./routes/addUserAsTempVIP";
export function createServer(callback: () => void): Server { export function createServer(callback: () => void): Server {
// Create a service (the app object is just a callback). // 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 //Endpoint used to make a user a VIP user with special privileges
router.post("/api/addUserAsVIP", addUserAsVIP); 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 //Gets all the views added up for one userID
//Useful to see how much one user has contributed //Useful to see how much one user has contributed

View File

@@ -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<APIVideoInfo> {
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<Response> {
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);
}

View File

@@ -9,11 +9,13 @@ import { getFormattedTime } from "../utils/getFormattedTime";
import { getIP } from "../utils/getIP"; import { getIP } from "../utils/getIP";
import { getHashCache } from "../utils/getHashCache"; import { getHashCache } from "../utils/getHashCache";
import { config } from "../config"; import { config } from "../config";
import { UserID } from "../types/user.model"; import { HashedUserID, UserID } from "../types/user.model";
import { Category, CategoryActionType, HashedIP, IPAddress, SegmentUUID, Service, VideoID, VideoIDHash, Visibility, VideoDuration } from "../types/segments.model"; import { Category, CategoryActionType, HashedIP, IPAddress, SegmentUUID, Service, VideoID, VideoIDHash, Visibility, VideoDuration } from "../types/segments.model";
import { getCategoryActionType } from "../utils/categoryInfo"; import { getCategoryActionType } from "../utils/categoryInfo";
import { QueryCacher } from "../utils/queryCacher"; import { QueryCacher } from "../utils/queryCacher";
import axios from "axios"; import axios from "axios";
import redis from "../utils/redis";
import { tempVIPKey } from "../utils/redisKeys";
const voteTypes = { const voteTypes = {
normal: 0, normal: 0,
@@ -37,6 +39,7 @@ interface VoteData {
UUID: string; UUID: string;
nonAnonUserID: string; nonAnonUserID: string;
voteTypeEnum: number; voteTypeEnum: number;
isTempVIP: boolean;
isVIP: boolean; isVIP: boolean;
isOwnSubmission: boolean; isOwnSubmission: boolean;
row: { row: {
@@ -57,6 +60,13 @@ function getYouTubeVideoInfo(videoID: VideoID, ignoreCache = false): Promise<API
} }
} }
const isUserTempVIP = async (nonAnonUserID: HashedUserID, videoID: VideoID): Promise<boolean> => {
const apiVideoInfo = await getYouTubeVideoInfo(videoID);
const channelID = apiVideoInfo?.data?.authorId;
const { err, reply } = await redis.getAsync(tempVIPKey(nonAnonUserID));
return err ? false : (reply == channelID);
};
const videoDurationChanged = (segmentDuration: number, APIDuration: number) => (APIDuration > 0 && Math.abs(segmentDuration - APIDuration) > 2); const videoDurationChanged = (segmentDuration: number, APIDuration: number) => (APIDuration > 0 && Math.abs(segmentDuration - APIDuration) > 2);
async function checkVideoDurationChange(UUID: SegmentUUID) { async function checkVideoDurationChange(UUID: SegmentUUID) {
@@ -105,7 +115,7 @@ async function sendWebhooks(voteData: VoteData) {
// Send custom webhooks // Send custom webhooks
dispatchEvent(isUpvote ? "vote.up" : "vote.down", { dispatchEvent(isUpvote ? "vote.up" : "vote.down", {
"user": { "user": {
"status": getVoteAuthorRaw(userSubmissionCountRow.submissionCount, voteData.isVIP, voteData.isOwnSubmission), "status": getVoteAuthorRaw(userSubmissionCountRow.submissionCount, voteData.isTempVIP, voteData.isVIP, voteData.isOwnSubmission),
}, },
"video": { "video": {
"id": submissionInfoRow.videoID, "id": submissionInfoRow.videoID,
@@ -153,7 +163,7 @@ async function sendWebhooks(voteData: VoteData) {
"author": { "author": {
"name": voteData.finalResponse?.webhookMessage ?? "name": voteData.finalResponse?.webhookMessage ??
voteData.finalResponse?.finalMessage ?? voteData.finalResponse?.finalMessage ??
getVoteAuthor(userSubmissionCountRow.submissionCount, voteData.isVIP, voteData.isOwnSubmission), getVoteAuthor(userSubmissionCountRow.submissionCount, voteData.isTempVIP, voteData.isVIP, voteData.isOwnSubmission),
}, },
"thumbnail": { "thumbnail": {
"url": getMaxResThumbnail(data) || "", "url": getMaxResThumbnail(data) || "",
@@ -311,7 +321,9 @@ export async function voteOnSponsorTime(req: Request, res: Response): Promise<Re
const hashedIP: HashedIP = await getHashCache((ip + config.globalSalt) as IPAddress); const hashedIP: HashedIP = await getHashCache((ip + config.globalSalt) as IPAddress);
//check if this user is on the vip list //check if this user is on the vip list
const isVIP = await isUserVIP(nonAnonUserID); const videoID = await db.prepare("get", `select "videoID" from "sponsorTimes" where "UUID" = ?`, [UUID]);
const isTempVIP = await isUserTempVIP(nonAnonUserID, videoID?.videoID || null);
const isVIP = await isUserVIP(nonAnonUserID) || isTempVIP;
//check if user voting on own submission //check if user voting on own submission
const isOwnSubmission = (await db.prepare("get", `SELECT "UUID" as "submissionCount" FROM "sponsorTimes" where "userID" = ? AND "UUID" = ?`, [nonAnonUserID, UUID])) !== undefined; const isOwnSubmission = (await db.prepare("get", `SELECT "UUID" as "submissionCount" FROM "sponsorTimes" where "userID" = ? AND "UUID" = ?`, [nonAnonUserID, UUID])) !== undefined;
@@ -480,6 +492,7 @@ export async function voteOnSponsorTime(req: Request, res: Response): Promise<Re
UUID, UUID,
nonAnonUserID, nonAnonUserID,
voteTypeEnum, voteTypeEnum,
isTempVIP,
isVIP, isVIP,
isOwnSubmission, isOwnSubmission,
row: videoInfo, row: videoInfo,

View File

@@ -7,6 +7,7 @@ interface RedisSB {
getAsync?(key: string): Promise<{err: Error | null, reply: string | null}>; getAsync?(key: string): Promise<{err: Error | null, reply: string | null}>;
set(key: string, value: string, callback?: Callback<string | null>): void; set(key: string, value: string, callback?: Callback<string | null>): void;
setAsync?(key: string, value: string): Promise<{err: Error | null, reply: string | null}>; 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<Error | null>; delAsync?(...keys: [string]): Promise<Error | null>;
close?(flush?: boolean): void; close?(flush?: boolean): void;
increment?(key: string): Promise<{err: Error| null, replies: any[] | null}>; increment?(key: string): Promise<{err: Error| null, replies: any[] | null}>;
@@ -19,6 +20,8 @@ let exportObject: RedisSB = {
set: (key, value, callback) => callback(null, undefined), set: (key, value, callback) => callback(null, undefined),
setAsync: () => setAsync: () =>
new Promise((resolve) => resolve({ err: null, reply: undefined })), new Promise((resolve) => resolve({ err: null, reply: undefined })),
setAsyncEx: () =>
new Promise((resolve) => resolve({ err: null, reply: undefined })),
delAsync: () => delAsync: () =>
new Promise((resolve) => resolve(null)), new Promise((resolve) => resolve(null)),
increment: () => increment: () =>
@@ -32,6 +35,7 @@ if (config.redis) {
exportObject.getAsync = (key) => new Promise((resolve) => client.get(key, (err, reply) => resolve({ err, reply }))); 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.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.delAsync = (...keys) => new Promise((resolve) => client.del(keys, (err) => resolve(err)));
exportObject.close = (flush) => client.end(flush); exportObject.close = (flush) => client.end(flush);
exportObject.increment = (key) => new Promise((resolve) => exportObject.increment = (key) => new Promise((resolve) =>

View File

@@ -1,15 +1,13 @@
import { Service, VideoID, VideoIDHash } from "../types/segments.model"; 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 { HashedValue } from "../types/hash.model";
import { Logger } from "./logger"; import { Logger } from "./logger";
export function skipSegmentsKey(videoID: VideoID, service: Service): string { export const skipSegmentsKey = (videoID: VideoID, service: Service): string =>
return `segments.v3.${service}.videoID.${videoID}`; `segments.v3.${service}.videoID.${videoID}`;
}
export function skipSegmentGroupsKey(videoID: VideoID, service: Service): string { export const skipSegmentGroupsKey = (videoID: VideoID, service: Service): string =>
return `segments.groups.v2.${service}.videoID.${videoID}`; `segments.groups.v2.${service}.videoID.${videoID}`;
}
export function skipSegmentsHashKey(hashedVideoIDPrefix: VideoIDHash, service: Service): string { export function skipSegmentsHashKey(hashedVideoIDPrefix: VideoIDHash, service: Service): string {
hashedVideoIDPrefix = hashedVideoIDPrefix.substring(0, 4) as VideoIDHash; hashedVideoIDPrefix = hashedVideoIDPrefix.substring(0, 4) as VideoIDHash;
@@ -18,9 +16,8 @@ export function skipSegmentsHashKey(hashedVideoIDPrefix: VideoIDHash, service: S
return `segments.v3.${service}.${hashedVideoIDPrefix}`; return `segments.v3.${service}.${hashedVideoIDPrefix}`;
} }
export function reputationKey(userID: UserID): string { export const reputationKey = (userID: UserID): string =>
return `reputation.user.${userID}`; `reputation.user.${userID}`;
}
export function ratingHashKey(hashPrefix: VideoIDHash, service: Service): string { export function ratingHashKey(hashPrefix: VideoIDHash, service: Service): string {
hashPrefix = hashPrefix.substring(0, 4) as VideoIDHash; hashPrefix = hashPrefix.substring(0, 4) as VideoIDHash;
@@ -33,4 +30,7 @@ export function shaHashKey(singleIter: HashedValue): string {
if (singleIter.length !== 64) Logger.warn(`Redis sha.hash key is not length 64! ${singleIter}`); if (singleIter.length !== 64) Logger.warn(`Redis sha.hash key is not length 64! ${singleIter}`);
return `sha.hash.${singleIter}`; return `sha.hash.${singleIter}`;
} }
export const tempVIPKey = (userID: HashedUserID): string =>
`vip.temp.${userID}`;

View File

@@ -2,9 +2,11 @@ import { config } from "../config";
import { Logger } from "../utils/logger"; import { Logger } from "../utils/logger";
import axios from "axios"; import axios from "axios";
function getVoteAuthorRaw(submissionCount: number, isVIP: boolean, isOwnSubmission: boolean): string { function getVoteAuthorRaw(submissionCount: number, isTempVIP: boolean, isVIP: boolean, isOwnSubmission: boolean): string {
if (isOwnSubmission) { if (isOwnSubmission) {
return "self"; return "self";
} else if (isTempVIP) {
return "temp vip";
} else if (isVIP) { } else if (isVIP) {
return "vip"; return "vip";
} else if (submissionCount === 0) { } else if (submissionCount === 0) {
@@ -14,11 +16,13 @@ function getVoteAuthorRaw(submissionCount: number, isVIP: boolean, isOwnSubmissi
} }
} }
function getVoteAuthor(submissionCount: number, isVIP: boolean, isOwnSubmission: boolean): string { function getVoteAuthor(submissionCount: number, isTempVIP: boolean, isVIP: boolean, isOwnSubmission: boolean): string {
if (submissionCount === 0) { if (submissionCount === 0) {
return "Report by New User"; return "Report by New User";
} else if (isOwnSubmission) { } else if (isOwnSubmission) {
return "Report by Submitter"; return "Report by Submitter";
} else if (isTempVIP) {
return "Report by Temp VIP";
} else if (isVIP) { } else if (isVIP) {
return "Report by VIP User"; return "Report by VIP User";
} }

165
test/cases/tempVip.ts Normal file
View File

@@ -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));
});
});

View File

@@ -47,6 +47,15 @@ export class YouTubeApiMock {
] ]
} as APIVideoData } as APIVideoData
}; };
} else if (obj.id === "channelid-convert") {
return {
err: null,
data: {
title: "Video Lookup Title",
author: "ChannelAuthor",
authorId: "ChannelID"
} as APIVideoData
};
} else { } else {
return { return {
err: null, err: null,