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")
);

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 };
@ -54,3 +55,27 @@ 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);
});
});