Add vote/submission for titles and thumbnails

This commit is contained in:
Ajay 2023-01-27 22:36:29 -05:00
parent 2a7083b9ef
commit 07c683e8f0
8 changed files with 543 additions and 14 deletions

View file

@ -26,26 +26,18 @@ CREATE TABLE IF NOT EXISTS "config" (
"value" TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS "titles" (
"UUID" TEXT NOT NULL PRIMARY KEY,
"hashedIP" TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS "titleVotes" (
"id" SERIAL PRIMARY KEY,
"videoID" TEXT NOT NULL,
"UUID" TEXT NOT NULL,
"userID" TEXT NOT NULL,
"hashedIP" TEXT NOT NULL,
"type" INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS "thumbnails" (
"UUID" TEXT NOT NULL PRIMARY KEY,
"hashedIP" TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS "thumbnailVotes" (
"id" SERIAL PRIMARY KEY,
"videoID" TEXT NOT NULL,
"UUID" TEXT NOT NULL,
"userID" TEXT NOT NULL,
"hashedIP" TEXT NOT NULL,

View file

@ -68,7 +68,7 @@ CREATE TABLE IF NOT EXISTS "thumbnails" (
CREATE TABLE IF NOT EXISTS "thumbnailTimestamps" (
"UUID" TEXT NOT NULL PRIMARY KEY,
"timestamp" INTEGER NOT NULL default 0
"timestamp" INTEGER NOT NULL default 0,
FOREIGN KEY("UUID") REFERENCES "thumbnails"("UUID")
);
@ -76,7 +76,7 @@ CREATE TABLE IF NOT EXISTS "thumbnailVotes" (
"UUID" TEXT NOT NULL PRIMARY KEY,
"votes" INTEGER NOT NULL default 0,
"locked" INTEGER NOT NULL default 0,
"shadowHidden" INTEGER NOT NULL default 0,
"shadowHidden" INTEGER NOT NULL default 0,
FOREIGN KEY("UUID") REFERENCES "thumbnails"("UUID")
);

View file

@ -50,6 +50,8 @@ import { getVideoLabelsByHash } from "./routes/getVideoLabelByHash";
import { addFeature } from "./routes/addFeature";
import { generateTokenRequest } from "./routes/generateToken";
import { verifyTokenRequest } from "./routes/verifyToken";
import { getBranding, getBrandingByHashEndpoint } from "./routes/getBranding";
import { postBranding } from "./routes/postBranding";
export function createServer(callback: () => void): Server {
// Create a service (the app object is just a callback).
@ -206,6 +208,10 @@ function setupRoutes(router: Router) {
router.get("/api/videoLabels", getVideoLabels);
router.get("/api/videoLabels/:prefix", getVideoLabelsByHash);
router.get("/api/branding", getBranding);
router.get("/api/branding/:prefix", getBrandingByHashEndpoint);
router.post("/api/branding", postBranding);
/* istanbul ignore next */
if (config.postgres?.enabled) {
router.get("/database", (req, res) => dumpDatabase(req, res, true));

View file

@ -14,6 +14,21 @@ export class Sqlite implements IDatabase {
// eslint-disable-next-line require-await
async prepare(type: QueryType, query: string, params: any[] = []): Promise<any[]> {
if (query.includes(";")) {
const promises = [];
let paramsCount = 0;
for (const q of query.split(";")) {
if (q.trim() !== "") {
const currentParamCount = q.match(/\?/g)?.length ?? 0;
promises.push(this.prepare(type, q, params.slice(paramsCount, paramsCount + currentParamCount)));
paramsCount += currentParamCount;
}
}
return (await Promise.all(promises)).flat();
}
// Logger.debug(`prepare (sqlite): type: ${type}, query: ${query}, params: ${params}`);
const preparedQuery = this.db.prepare(Sqlite.processQuery(query));

View file

@ -186,7 +186,7 @@ export async function getBranding(req: Request, res: Response) {
return res.status(status).json(result);
}
export async function getBrandingByHash(req: Request, res: Response) {
export async function getBrandingByHashEndpoint(req: Request, res: Response) {
let hashPrefix = req.params.prefix as VideoIDHash;
if (!req.params.prefix || !hashPrefixTester(req.params.prefix)) {
return res.status(400).send("Hash prefix does not match format requirements."); // Exit early on faulty prefix

129
src/routes/postBranding.ts Normal file
View file

@ -0,0 +1,129 @@
import { Request, Response } from "express";
import { config } from "../config";
import { db, privateDB } from "../databases/databases";
import { BrandingSubmission, BrandingUUID, TimeThumbnailSubmission } from "../types/branding.model";
import { HashedIP, IPAddress, VideoID } from "../types/segments.model";
import { HashedUserID } from "../types/user.model";
import { getHashCache } from "../utils/getHashCache";
import { getIP } from "../utils/getIP";
import { getService } from "../utils/getService";
import { isUserVIP } from "../utils/isUserVIP";
import { Logger } from "../utils/logger";
enum BrandingType {
Title,
Thumbnail
}
interface ExistingVote {
UUID: BrandingUUID;
type: number;
id: number;
}
export async function postBranding(req: Request, res: Response) {
const { videoID, userID, title, thumbnail } = req.body as BrandingSubmission;
const service = getService(req.body.service);
if (!videoID || !userID || userID.length < 30 || !service
|| ((!title || !title.title)
&& (!thumbnail || thumbnail.original == null
|| (!thumbnail.original && !(thumbnail as TimeThumbnailSubmission).timestamp)))) {
res.status(400).send("Bad Request");
return;
}
try {
const hashedUserID = await getHashCache(userID);
const isVip = await isUserVIP(hashedUserID);
const hashedVideoID = await getHashCache(videoID, 1);
const hashedIP = await getHashCache(getIP(req) + config.globalSalt as IPAddress);
const now = Date.now();
const voteType = 1;
await Promise.all([(async () => {
if (title) {
const existingUUID = (await db.prepare("get", `SELECT "UUID" from "titles" where "videoID" = ? AND "title" = ?`, [videoID, title.title]))?.UUID;
const UUID = existingUUID || crypto.randomUUID();
const existingVote = await handleExistingVotes(BrandingType.Title, videoID, hashedUserID, UUID, hashedIP, voteType);
if (existingUUID) {
await updateVoteTotals(BrandingType.Title, existingVote, UUID, isVip);
} else {
await db.prepare("run", `INSERT INTO "titles" ("videoID", "title", "original", "userID", "service", "hashedVideoID", "timeSubmitted", "UUID") VALUES (?, ?, ?, ?, ?, ?, ?, ?);
INSERT INTO "titleVotes" ("UUID", "votes", "locked", "shadowHidden") VALUES (?, 0, ?, 0);`,
[videoID, title.title, title.original ? 1 : 0, hashedUserID, service, hashedVideoID, now, UUID, UUID, isVip ? 1 : 0]);
}
}
})(), (async () => {
if (thumbnail) {
const existingUUID = thumbnail.original
? (await db.prepare("get", `SELECT "UUID" from "thumbnails" where "videoID" = ? AND "original" = 1`, [videoID]))?.UUID
: (await db.prepare("get", `SELECT "thumbnails"."UUID" from "thumbnailTimestamps" JOIN "thumbnails" ON "thumbnails"."UUID" = "thumbnailTimestamps"."UUID"
WHERE "thumbnailTimestamps"."timestamp" = ? AND "thumbnails"."videoID" = ?`, [(thumbnail as TimeThumbnailSubmission).timestamp, videoID]))?.UUID;
const UUID = existingUUID || crypto.randomUUID();
const existingVote = await handleExistingVotes(BrandingType.Thumbnail, videoID, hashedUserID, UUID, hashedIP, voteType);
if (existingUUID) {
await updateVoteTotals(BrandingType.Thumbnail, existingVote, UUID, isVip);
} else {
await db.prepare("run", `INSERT INTO "thumbnails" ("videoID", "original", "userID", "service", "hashedVideoID", "timeSubmitted", "UUID") VALUES (?, ?, ?, ?, ?, ?, ?);
INSERT INTO "thumbnailVotes" ("UUID", "votes", "locked", "shadowHidden") VALUES (?, 0, ?, 0);
${thumbnail.original ? "" : `INSERT INTO "thumbnailTimestamps" ("UUID", "timestamp") VALUES (?, ?)`}`,
[videoID, thumbnail.original ? 1 : 0, hashedUserID, service, hashedVideoID, now, UUID, UUID,
isVip ? 1 : 0, thumbnail.original ? null : UUID, thumbnail.original ? null : (thumbnail as TimeThumbnailSubmission).timestamp]);
}
}
})()]);
res.status(200).send("OK");
} catch (e) {
Logger.error(e as string);
res.status(500).send("Internal Server Error");
}
}
/**
* Finds an existing vote, if found, and it's for a different submission, it undoes it, and points to the new submission.
* If no existing vote, it adds one.
*/
async function handleExistingVotes(type: BrandingType, videoID: VideoID,
hashedUserID: HashedUserID, UUID: BrandingUUID, hashedIP: HashedIP, voteType: number): Promise<ExistingVote> {
const table = type === BrandingType.Title ? `"titleVotes"` : `"thumbnailVotes"`;
const existingVote = await privateDB.prepare("get", `SELECT "id", "UUID", "type" from ${table} where "videoID" = ? AND "userID" = ?`, [videoID, hashedUserID]);
if (existingVote && existingVote.UUID !== UUID) {
if (existingVote.type === 1) {
await db.prepare("run", `UPDATE ${table} SET "votes" = "votes" - 1 WHERE "UUID" = ?`, [existingVote.UUID]);
}
await privateDB.prepare("run", `UPDATE ${table} SET "type" = ?, "UUID" = ? WHERE "id" = ?`, [voteType, UUID, existingVote.id]);
} else if (!existingVote) {
await privateDB.prepare("run", `INSERT INTO ${table} ("videoID", "UUID", "userID", "hashedIP", "type") VALUES (?, ?, ?, ?, ?)`,
[videoID, UUID, hashedUserID, hashedIP, voteType]);
}
return existingVote;
}
/**
* Only called if an existing vote exists.
* Will update public vote totals and locked status.
*/
async function updateVoteTotals(type: BrandingType, existingVote: ExistingVote, UUID: BrandingUUID, isVip: boolean): Promise<void> {
if (!existingVote) return;
const table = type === BrandingType.Title ? `"titleVotes"` : `"thumbnailVotes"`;
// Don't upvote if we vote on the same submission
if (!existingVote || existingVote.UUID !== UUID) {
await db.prepare("run", `UPDATE ${table} SET "votes" = "votes" + 1 WHERE "UUID" = ?`, [UUID]);
}
if (isVip) {
await db.prepare("run", `UPDATE ${table} SET "locked" = 1 WHERE "UUID" = ?`, [UUID]);
}
}

View file

@ -1,4 +1,5 @@
import { VideoID, VideoIDHash } from "./segments.model";
import { Service, VideoID, VideoIDHash } from "./segments.model";
import { UserID } from "./user.model";
export type BrandingUUID = string & { readonly __brandingUUID: unique symbol };
@ -53,4 +54,28 @@ export interface BrandingHashDBResult {
export interface BrandingHashResult {
branding: BrandingResult;
}
export interface OriginalThumbnailSubmission {
original: true;
}
export interface TimeThumbnailSubmission {
timestamp: number;
original: false;
}
export type ThumbnailSubmission = OriginalThumbnailSubmission | TimeThumbnailSubmission;
export interface TitleSubmission {
title: string;
original: boolean;
}
export interface BrandingSubmission {
title: TitleSubmission;
thumbnail: ThumbnailSubmission;
videoID: VideoID;
userID: UserID;
service: Service;
}

362
test/cases/postBranding.ts Normal file
View file

@ -0,0 +1,362 @@
import { db } from "../../src/databases/databases";
import { getHash } from "../../src/utils/getHash";
import { client } from "../utils/httpClient";
import assert from "assert";
import { Service } from "../../src/types/segments.model";
describe("postBranding", () => {
const vipUser = `VIPPostBrandingUser${".".repeat(16)}`;
const userID1 = `PostBrandingUser1${".".repeat(16)}`;
const userID2 = `PostBrandingUser2${".".repeat(16)}`;
const userID3 = `PostBrandingUser3${".".repeat(16)}`;
const userID4 = `PostBrandingUser4${".".repeat(16)}`;
const userID5 = `PostBrandingUser4${".".repeat(16)}`;
const endpoint = "/api/branding";
const postBranding = (data: Record<string, any>) => client({
method: "POST",
url: endpoint,
data
});
const queryTitleByVideo = (videoID: string) => db.prepare("get", `SELECT * FROM "titles" WHERE "videoID" = ? ORDER BY "timeSubmitted" DESC`, [videoID]);
const queryThumbnailByVideo = (videoID: string) => db.prepare("get", `SELECT * FROM "thumbnails" WHERE "videoID" = ? ORDER BY "timeSubmitted" DESC`, [videoID]);
const queryThumbnailTimestampsByUUID = (UUID: string) => db.prepare("get", `SELECT * FROM "thumbnailTimestamps" WHERE "UUID" = ?`, [UUID]);
const queryTitleVotesByUUID = (UUID: string) => db.prepare("get", `SELECT * FROM "titleVotes" WHERE "UUID" = ?`, [UUID]);
const queryThumbnailVotesByUUID = (UUID: string) => db.prepare("get", `SELECT * FROM "thumbnailVotes" WHERE "UUID" = ?`, [UUID]);
before(() => {
const insertVipUserQuery = 'INSERT INTO "vipUsers" ("userID") VALUES (?)';
db.prepare("run", insertVipUserQuery, [getHash(vipUser)]);
});
it("Submit only title", async () => {
const videoID = "postBrand1";
const title = {
title: "Some title",
original: false
};
const res = await postBranding({
title,
userID: userID1,
service: Service.YouTube,
videoID
});
assert.strictEqual(res.status, 200);
const dbTitle = await queryTitleByVideo(videoID);
const dbVotes = await queryTitleVotesByUUID(dbTitle.UUID);
assert.strictEqual(dbTitle.title, title.title);
assert.strictEqual(dbTitle.original, title.original ? 1 : 0);
assert.strictEqual(dbVotes.votes, 0);
assert.strictEqual(dbVotes.locked, 0);
assert.strictEqual(dbVotes.shadowHidden, 0);
});
it("Submit only original title", async () => {
const videoID = "postBrand2";
const title = {
title: "Some title",
original: true
};
const res = await postBranding({
title,
userID: userID2,
service: Service.YouTube,
videoID
});
assert.strictEqual(res.status, 200);
const dbTitle = await queryTitleByVideo(videoID);
const dbVotes = await queryTitleVotesByUUID(dbTitle.UUID);
assert.strictEqual(dbTitle.title, title.title);
assert.strictEqual(dbTitle.original, title.original ? 1 : 0);
assert.strictEqual(dbVotes.votes, 0);
assert.strictEqual(dbVotes.locked, 0);
assert.strictEqual(dbVotes.shadowHidden, 0);
});
it("Submit only original thumbnail", async () => {
const videoID = "postBrand3";
const thumbnail = {
original: true
};
const res = await postBranding({
thumbnail,
userID: userID3,
service: Service.YouTube,
videoID
});
assert.strictEqual(res.status, 200);
const dbThumbnail = await queryThumbnailByVideo(videoID);
const dbVotes = await queryThumbnailVotesByUUID(dbThumbnail.UUID);
assert.strictEqual(dbThumbnail.original, thumbnail.original ? 1 : 0);
assert.strictEqual(dbVotes.votes, 0);
assert.strictEqual(dbVotes.locked, 0);
assert.strictEqual(dbVotes.shadowHidden, 0);
});
it("Submit only custom thumbnail", async () => {
const videoID = "postBrand4";
const thumbnail = {
timestamp: 12.42,
original: false
};
const res = await postBranding({
thumbnail,
userID: userID4,
service: Service.YouTube,
videoID
});
assert.strictEqual(res.status, 200);
const dbThumbnail = await queryThumbnailByVideo(videoID);
const dbThumbnailTimestamps = await queryThumbnailTimestampsByUUID(dbThumbnail.UUID);
const dbVotes = await queryThumbnailVotesByUUID(dbThumbnail.UUID);
assert.strictEqual(dbThumbnailTimestamps.timestamp, thumbnail.timestamp);
assert.strictEqual(dbThumbnail.original, thumbnail.original ? 1 : 0);
assert.strictEqual(dbVotes.votes, 0);
assert.strictEqual(dbVotes.locked, 0);
assert.strictEqual(dbVotes.shadowHidden, 0);
});
it("Submit title and thumbnail", async () => {
const videoID = "postBrand5";
const title = {
title: "Some title",
original: false
};
const thumbnail = {
timestamp: 12.42,
original: false
};
const res = await postBranding({
title,
thumbnail,
userID: userID5,
service: Service.YouTube,
videoID
});
assert.strictEqual(res.status, 200);
const dbTitle = await queryTitleByVideo(videoID);
const dbTitleVotes = await queryTitleVotesByUUID(dbTitle.UUID);
const dbThumbnail = await queryThumbnailByVideo(videoID);
const dbThumbnailTimestamps = await queryThumbnailTimestampsByUUID(dbThumbnail.UUID);
const dbThumbnailVotes = await queryThumbnailVotesByUUID(dbThumbnail.UUID);
assert.strictEqual(dbTitle.title, title.title);
assert.strictEqual(dbTitle.original, title.original ? 1 : 0);
assert.strictEqual(dbTitleVotes.votes, 0);
assert.strictEqual(dbTitleVotes.locked, 0);
assert.strictEqual(dbTitleVotes.shadowHidden, 0);
assert.strictEqual(dbThumbnailTimestamps.timestamp, thumbnail.timestamp);
assert.strictEqual(dbThumbnail.original, thumbnail.original ? 1 : 0);
assert.strictEqual(dbThumbnailVotes.votes, 0);
assert.strictEqual(dbThumbnailVotes.locked, 0);
assert.strictEqual(dbThumbnailVotes.shadowHidden, 0);
});
it("Submit title and thumbnail as VIP", async () => {
const videoID = "postBrand6";
const title = {
title: "Some title",
original: false
};
const thumbnail = {
timestamp: 12.42,
original: false
};
const res = await postBranding({
title,
thumbnail,
userID: vipUser,
service: Service.YouTube,
videoID
});
assert.strictEqual(res.status, 200);
const dbTitle = await queryTitleByVideo(videoID);
const dbTitleVotes = await queryTitleVotesByUUID(dbTitle.UUID);
const dbThumbnail = await queryThumbnailByVideo(videoID);
const dbThumbnailTimestamps = await queryThumbnailTimestampsByUUID(dbThumbnail.UUID);
const dbThumbnailVotes = await queryThumbnailVotesByUUID(dbThumbnail.UUID);
assert.strictEqual(dbTitle.title, title.title);
assert.strictEqual(dbTitle.original, title.original ? 1 : 0);
assert.strictEqual(dbTitleVotes.votes, 0);
assert.strictEqual(dbTitleVotes.locked, 1);
assert.strictEqual(dbTitleVotes.shadowHidden, 0);
assert.strictEqual(dbThumbnailTimestamps.timestamp, thumbnail.timestamp);
assert.strictEqual(dbThumbnail.original, thumbnail.original ? 1 : 0);
assert.strictEqual(dbThumbnailVotes.votes, 0);
assert.strictEqual(dbThumbnailVotes.locked, 1);
assert.strictEqual(dbThumbnailVotes.shadowHidden, 0);
});
it("Vote the same title again", async () => {
const videoID = "postBrand1";
const title = {
title: "Some title",
original: false
};
const res = await postBranding({
title,
userID: userID1,
service: Service.YouTube,
videoID
});
assert.strictEqual(res.status, 200);
const dbTitle = await queryTitleByVideo(videoID);
const dbVotes = await queryTitleVotesByUUID(dbTitle.UUID);
assert.strictEqual(dbTitle.title, title.title);
assert.strictEqual(dbTitle.original, title.original ? 1 : 0);
assert.strictEqual(dbVotes.votes, 0);
assert.strictEqual(dbVotes.locked, 0);
assert.strictEqual(dbVotes.shadowHidden, 0);
});
it("Vote for a different title", async () => {
const videoID = "postBrand1";
const title = {
title: "Some other title",
original: false
};
const oldDbTitle = await queryTitleByVideo(videoID);
const res = await postBranding({
title,
userID: userID1,
service: Service.YouTube,
videoID
});
assert.strictEqual(res.status, 200);
const dbTitle = await queryTitleByVideo(videoID);
const dbVotes = await queryTitleVotesByUUID(dbTitle.UUID);
const oldDBVotes = await queryTitleVotesByUUID(oldDbTitle.UUID);
assert.strictEqual(dbTitle.title, title.title);
assert.strictEqual(dbTitle.original, title.original ? 1 : 0);
assert.strictEqual(dbVotes.votes, 0);
assert.strictEqual(dbVotes.locked, 0);
assert.strictEqual(dbVotes.shadowHidden, 0);
assert.strictEqual(oldDBVotes.votes, -1);
assert.strictEqual(oldDBVotes.locked, 0);
assert.strictEqual(oldDBVotes.shadowHidden, 0);
});
it("Vote for the same thumbnail again", async () => {
const videoID = "postBrand4";
const thumbnail = {
timestamp: 12.42,
original: false
};
const res = await postBranding({
thumbnail,
userID: userID4,
service: Service.YouTube,
videoID
});
assert.strictEqual(res.status, 200);
const dbThumbnail = await queryThumbnailByVideo(videoID);
const dbThumbnailTimestamps = await queryThumbnailTimestampsByUUID(dbThumbnail.UUID);
const dbVotes = await queryThumbnailVotesByUUID(dbThumbnail.UUID);
assert.strictEqual(dbThumbnailTimestamps.timestamp, thumbnail.timestamp);
assert.strictEqual(dbThumbnail.original, thumbnail.original ? 1 : 0);
assert.strictEqual(dbVotes.votes, 0);
assert.strictEqual(dbVotes.locked, 0);
assert.strictEqual(dbVotes.shadowHidden, 0);
});
it("Vote for the same thumbnail again original", async () => {
const videoID = "postBrand3";
const thumbnail = {
original: true
};
const res = await postBranding({
thumbnail,
userID: userID3,
service: Service.YouTube,
videoID
});
assert.strictEqual(res.status, 200);
const dbThumbnail = await queryThumbnailByVideo(videoID);
const dbVotes = await queryThumbnailVotesByUUID(dbThumbnail.UUID);
assert.strictEqual(dbThumbnail.original, thumbnail.original ? 1 : 0);
assert.strictEqual(dbVotes.votes, 0);
assert.strictEqual(dbVotes.locked, 0);
assert.strictEqual(dbVotes.shadowHidden, 0);
});
it("Vote for a different thumbnail", async () => {
const videoID = "postBrand4";
const thumbnail = {
timestamp: 15.34,
original: false
};
const oldDbThumbnail = await queryThumbnailByVideo(videoID);
const res = await postBranding({
thumbnail,
userID: userID4,
service: Service.YouTube,
videoID
});
assert.strictEqual(res.status, 200);
const dbThumbnail = await queryThumbnailByVideo(videoID);
const dbThumbnailTimestamps = await queryThumbnailTimestampsByUUID(dbThumbnail.UUID);
const dbVotes = await queryThumbnailVotesByUUID(dbThumbnail.UUID);
const oldDBVotes = await queryThumbnailVotesByUUID(oldDbThumbnail.UUID);
assert.strictEqual(dbThumbnailTimestamps.timestamp, thumbnail.timestamp);
assert.strictEqual(dbThumbnail.original, thumbnail.original ? 1 : 0);
assert.strictEqual(dbVotes.votes, 0);
assert.strictEqual(dbVotes.locked, 0);
assert.strictEqual(dbVotes.shadowHidden, 0);
assert.strictEqual(oldDBVotes.votes, -1);
assert.strictEqual(oldDBVotes.locked, 0);
assert.strictEqual(oldDBVotes.shadowHidden, 0);
});
});