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)
[sponsorTimes](#sponsorTimes)
[config](#config)
[tempVipLog](#tempVipLog)
### vote
@@ -270,3 +271,11 @@
| index | field |
| -- | :--: |
| 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',
"type" INTEGER NOT NULL,
"userID" TEXT NOT NULL,
"timeSubmitted" INTEGER NOT NULL,
"timeSubmitted" INTEGER 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 { getRating } from "./routes/ratings/getRating";
import { postClearCache as ratingPostClearCache } from "./routes/ratings/postClearCache";
import { addUserAsTempVIP } from "./routes/addUserAsTempVIP";
export function createServer(callback: () => void): Server {
// 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
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
//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 { getHashCache } from "../utils/getHashCache";
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 { getCategoryActionType } from "../utils/categoryInfo";
import { QueryCacher } from "../utils/queryCacher";
import axios from "axios";
import redis from "../utils/redis";
import { tempVIPKey } from "../utils/redisKeys";
const voteTypes = {
normal: 0,
@@ -37,6 +39,7 @@ interface VoteData {
UUID: string;
nonAnonUserID: string;
voteTypeEnum: number;
isTempVIP: boolean;
isVIP: boolean;
isOwnSubmission: boolean;
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);
async function checkVideoDurationChange(UUID: SegmentUUID) {
@@ -105,7 +115,7 @@ async function sendWebhooks(voteData: VoteData) {
// Send custom webhooks
dispatchEvent(isUpvote ? "vote.up" : "vote.down", {
"user": {
"status": getVoteAuthorRaw(userSubmissionCountRow.submissionCount, voteData.isVIP, voteData.isOwnSubmission),
"status": getVoteAuthorRaw(userSubmissionCountRow.submissionCount, voteData.isTempVIP, voteData.isVIP, voteData.isOwnSubmission),
},
"video": {
"id": submissionInfoRow.videoID,
@@ -153,7 +163,7 @@ async function sendWebhooks(voteData: VoteData) {
"author": {
"name": voteData.finalResponse?.webhookMessage ??
voteData.finalResponse?.finalMessage ??
getVoteAuthor(userSubmissionCountRow.submissionCount, voteData.isVIP, voteData.isOwnSubmission),
getVoteAuthor(userSubmissionCountRow.submissionCount, voteData.isTempVIP, voteData.isVIP, voteData.isOwnSubmission),
},
"thumbnail": {
"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);
//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
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,
nonAnonUserID,
voteTypeEnum,
isTempVIP,
isVIP,
isOwnSubmission,
row: videoInfo,

View File

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

View File

@@ -1,15 +1,13 @@
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 { Logger } from "./logger";
export function skipSegmentsKey(videoID: VideoID, service: Service): string {
return `segments.v3.${service}.videoID.${videoID}`;
}
export const skipSegmentsKey = (videoID: VideoID, service: Service): string =>
`segments.v3.${service}.videoID.${videoID}`;
export function skipSegmentGroupsKey(videoID: VideoID, service: Service): string {
return `segments.groups.v2.${service}.videoID.${videoID}`;
}
export const skipSegmentGroupsKey = (videoID: VideoID, service: Service): string =>
`segments.groups.v2.${service}.videoID.${videoID}`;
export function skipSegmentsHashKey(hashedVideoIDPrefix: VideoIDHash, service: Service): string {
hashedVideoIDPrefix = hashedVideoIDPrefix.substring(0, 4) as VideoIDHash;
@@ -18,9 +16,8 @@ export function skipSegmentsHashKey(hashedVideoIDPrefix: VideoIDHash, service: S
return `segments.v3.${service}.${hashedVideoIDPrefix}`;
}
export function reputationKey(userID: UserID): string {
return `reputation.user.${userID}`;
}
export const reputationKey = (userID: UserID): string =>
`reputation.user.${userID}`;
export function ratingHashKey(hashPrefix: VideoIDHash, service: Service): string {
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}`);
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 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) {
return "self";
} else if (isTempVIP) {
return "temp vip";
} else if (isVIP) {
return "vip";
} 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) {
return "Report by New User";
} else if (isOwnSubmission) {
return "Report by Submitter";
} else if (isTempVIP) {
return "Report by Temp VIP";
} else if (isVIP) {
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
};
} else if (obj.id === "channelid-convert") {
return {
err: null,
data: {
title: "Video Lookup Title",
author: "ChannelAuthor",
authorId: "ChannelID"
} as APIVideoData
};
} else {
return {
err: null,