diff --git a/src/db/index.ts b/src/db/index.ts index 8db02c3..9505e9a 100644 --- a/src/db/index.ts +++ b/src/db/index.ts @@ -3,6 +3,7 @@ import { migrate } from "drizzle-orm/postgres-js/migrator"; import postgres from "postgres"; import { env } from "../env"; +import * as adminSchema from "./schema/admins"; import * as mediaSchema from "./schema/media"; import * as subscriberSchema from "./schema/subscribers"; import * as votingSchema from "./schema/voting"; @@ -11,6 +12,7 @@ const client = postgres(env.DATABASE_URL); export const db = drizzle(client, { schema: { + ...adminSchema, ...mediaSchema, ...subscriberSchema, ...votingSchema, diff --git a/src/db/migrations/0001_quiet_black_queen.sql b/src/db/migrations/0001_quiet_black_queen.sql new file mode 100644 index 0000000..01f8442 --- /dev/null +++ b/src/db/migrations/0001_quiet_black_queen.sql @@ -0,0 +1,6 @@ +CREATE TABLE IF NOT EXISTS "admins" ( + "discord_user_id" varchar PRIMARY KEY NOT NULL, + "otp" varchar NOT NULL, + "is_admin" boolean DEFAULT false NOT NULL, + "created_at" timestamp DEFAULT now() NOT NULL +); diff --git a/src/db/migrations/meta/0001_snapshot.json b/src/db/migrations/meta/0001_snapshot.json new file mode 100644 index 0000000..7ca17ce --- /dev/null +++ b/src/db/migrations/meta/0001_snapshot.json @@ -0,0 +1,459 @@ +{ + "id": "4fb76952-af32-4d09-85c8-85e7f1265774", + "prevId": "8c9ea751-7eea-48ac-8563-8d43a1e5725f", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.admins": { + "name": "admins", + "schema": "", + "columns": { + "discord_user_id": { + "name": "discord_user_id", + "type": "varchar", + "primaryKey": true, + "notNull": true + }, + "otp": { + "name": "otp", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "is_admin": { + "name": "is_admin", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.media_items": { + "name": "media_items", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "varchar", + "primaryKey": true, + "notNull": true + }, + "manager_id": { + "name": "manager_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "manager_type": { + "name": "manager_type", + "type": "manager_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "manager_config_id": { + "name": "manager_config_id", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "size_on_disk": { + "name": "size_on_disk", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "release_date": { + "name": "release_date", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "year": { + "name": "year", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "has_file": { + "name": "has_file", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "imdb_id": { + "name": "imdb_id", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "tmdb_id": { + "name": "tmdb_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "rating": { + "name": "rating", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "added_to_manager": { + "name": "added_to_manager", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "path_on_disk": { + "name": "path_on_disk", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "image": { + "name": "image", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": { + "manager_id_idx": { + "name": "manager_id_idx", + "columns": [ + { + "expression": "manager_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "manager_type_idx": { + "name": "manager_type_idx", + "columns": [ + { + "expression": "manager_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "manager_config_id_idx": { + "name": "manager_config_id_idx", + "columns": [ + { + "expression": "manager_config_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "added_to_manager_idx": { + "name": "added_to_manager_idx", + "columns": [ + { + "expression": "added_to_manager", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "media_items_manager_id_manager_config_id_unique": { + "name": "media_items_manager_id_manager_config_id_unique", + "nullsNotDistinct": false, + "columns": [ + "manager_id", + "manager_config_id" + ] + } + } + }, + "public.subscribers": { + "name": "subscribers", + "schema": "", + "columns": { + "discord_user_id": { + "name": "discord_user_id", + "type": "varchar", + "primaryKey": true, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.items_to_delete": { + "name": "items_to_delete", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "varchar", + "primaryKey": true, + "notNull": true + }, + "delete_after": { + "name": "delete_after", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "items_to_delete_id_media_items_id_fk": { + "name": "items_to_delete_id_media_items_id_fk", + "tableFrom": "items_to_delete", + "tableTo": "media_items", + "columnsFrom": [ + "id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.voting_sessions": { + "name": "voting_sessions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "varchar", + "primaryKey": true, + "notNull": true + }, + "discord_message_id": { + "name": "discord_message_id", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "ends_at": { + "name": "ends_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "handled": { + "name": "handled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "vote_outcome": { + "name": "vote_outcome", + "type": "vote_outcome", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "discord_message_id_idx": { + "name": "discord_message_id_idx", + "columns": [ + { + "expression": "discord_message_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "handled_idx": { + "name": "handled_idx", + "columns": [ + { + "expression": "handled", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "voting_sessions_id_media_items_id_fk": { + "name": "voting_sessions_id_media_items_id_fk", + "tableFrom": "voting_sessions", + "tableTo": "media_items", + "columnsFrom": [ + "id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.whitelist": { + "name": "whitelist", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "varchar", + "primaryKey": true, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "whitelist_id_media_items_id_fk": { + "name": "whitelist_id_media_items_id_fk", + "tableFrom": "whitelist", + "tableTo": "media_items", + "columnsFrom": [ + "id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + } + }, + "enums": { + "public.manager_type": { + "name": "manager_type", + "schema": "public", + "values": [ + "radarr", + "sonarr" + ] + }, + "public.vote_outcome": { + "name": "vote_outcome", + "schema": "public", + "values": [ + "keep", + "delete" + ] + } + }, + "schemas": {}, + "sequences": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/src/db/migrations/meta/_journal.json b/src/db/migrations/meta/_journal.json index 6ec19b4..e25af62 100644 --- a/src/db/migrations/meta/_journal.json +++ b/src/db/migrations/meta/_journal.json @@ -8,6 +8,13 @@ "when": 1726927500269, "tag": "0000_dry_sage", "breakpoints": true + }, + { + "idx": 1, + "version": "7", + "when": 1728727950318, + "tag": "0001_quiet_black_queen", + "breakpoints": true } ] } \ No newline at end of file diff --git a/src/db/schema/admins.ts b/src/db/schema/admins.ts new file mode 100644 index 0000000..c021558 --- /dev/null +++ b/src/db/schema/admins.ts @@ -0,0 +1,8 @@ +import { boolean, pgTable, timestamp, varchar } from "drizzle-orm/pg-core"; + +export const admins = pgTable("admins", { + discordUserId: varchar("discord_user_id").primaryKey(), + otp: varchar("otp").notNull(), + isAdmin: boolean("is_admin").notNull().default(false), + createdAt: timestamp("created_at").defaultNow().notNull(), +}); diff --git a/src/discord/commands/config.ts b/src/discord/commands/config.ts index 725ab1e..f585324 100644 --- a/src/discord/commands/config.ts +++ b/src/discord/commands/config.ts @@ -3,8 +3,8 @@ import { ApplicationCommandType, CommandInteraction } from "discord.js"; import { config } from "../../config"; import { discordClient } from "../client"; -discordClient.once("ready", () => { - discordClient.application?.commands.create({ +discordClient.once("ready", async () => { + await discordClient.application?.commands.create({ name: "config", description: "Show the current config", type: ApplicationCommandType.ChatInput, diff --git a/src/discord/commands/index.ts b/src/discord/commands/index.ts index cecc425..eb7154d 100644 --- a/src/discord/commands/index.ts +++ b/src/discord/commands/index.ts @@ -1,6 +1,8 @@ import "./config"; import "./deleted"; import "./help"; +import "./make-me-admin"; import "./next-vote"; import "./subscribe"; +import "./vote-now"; import "./whitelist"; diff --git a/src/discord/commands/make-me-admin.ts b/src/discord/commands/make-me-admin.ts new file mode 100644 index 0000000..160d685 --- /dev/null +++ b/src/discord/commands/make-me-admin.ts @@ -0,0 +1,84 @@ +import crypto from "crypto"; +import dayjs from "dayjs"; +import relativeTime from "dayjs/plugin/relativeTime"; +import { + ActionRowBuilder, + ApplicationCommandType, + CommandInteraction, + ModalBuilder, + TextInputBuilder, + TextInputStyle, +} from "discord.js"; + +import { approveAdmin, getAdminOtp, wantsAdmin } from "../../lib/admin"; +import { discordClient } from "../client"; + +dayjs.extend(relativeTime); + +discordClient.once("ready", async () => { + await discordClient.application?.commands.create({ + name: "make-me-admin", + description: "Request to be added to the admin list", + type: ApplicationCommandType.ChatInput, + }); + console.log("✅ make-me-admin command registered"); +}); + +discordClient.on("interactionCreate", async (interaction) => { + if (interaction.isCommand()) { + const { commandName } = interaction; + + if (commandName === "make-me-admin") { + await handleMakeMeAdmin(interaction); + } + } + + if (interaction.isModalSubmit()) { + if (interaction.customId === "otpModal") { + const submittedOtp = interaction.fields.getTextInputValue("otpInput"); + const storedOtp = await getAdminOtp(interaction.user.id); + + if (submittedOtp !== storedOtp) { + await interaction.reply({ + content: "Invalid OTP", + ephemeral: true, + }); + return; + } + + await approveAdmin(interaction.user.id); + + await interaction.reply({ + content: "Congratulations! You are now an admin.", + ephemeral: true, + }); + } + } +}); + +async function handleMakeMeAdmin(interaction: CommandInteraction) { + const newAdmin = await wantsAdmin(interaction.user.id); + console.log( + `⚡️ OTP for ${interaction.user.username} (${interaction.user.id}): ${newAdmin.otp}`, + ); + + // Create a modal + const modal = new ModalBuilder() + .setCustomId("otpModal") + .setTitle("Enter OTP"); + + // Add input field to the modal + const otpInput = new TextInputBuilder() + .setCustomId("otpInput") + .setLabel(`Enter the OTP`) + .setStyle(TextInputStyle.Short) + .setRequired(true); + + const actionRow = new ActionRowBuilder().addComponents( + otpInput, + ); + modal.addComponents(actionRow); + + // Show the modal to the user + await interaction.showModal(modal); +} diff --git a/src/discord/commands/vote-now.ts b/src/discord/commands/vote-now.ts new file mode 100644 index 0000000..3ad844b --- /dev/null +++ b/src/discord/commands/vote-now.ts @@ -0,0 +1,45 @@ +import dayjs from "dayjs"; +import relativeTime from "dayjs/plugin/relativeTime"; +import { ApplicationCommandType, CommandInteraction } from "discord.js"; + +import { isAdmin } from "../../lib/admin"; +import { determineDeletions } from "../../services/deletionService"; +import { updateDatabase } from "../../services/updateService"; +import { discordClient } from "../client"; + +dayjs.extend(relativeTime); + +discordClient.once("ready", async () => { + await discordClient.application?.commands.create({ + name: "vote-now", + description: "Create a vote right now (admin only)", + type: ApplicationCommandType.ChatInput, + }); + console.log("✅ vote-now command registered"); +}); + +discordClient.on("interactionCreate", async (interaction) => { + if (!interaction.isCommand()) return; + + const { commandName } = interaction; + + if (commandName === "vote-now") { + await handleVoteNow(interaction); + } +}); + +async function handleVoteNow(interaction: CommandInteraction) { + if (!(await isAdmin(interaction.user.id))) { + await interaction.reply({ + content: "You are not an admin", + ephemeral: true, + }); + return; + } + await updateDatabase(); + await determineDeletions(); + await interaction.reply({ + content: `Created new voting session`, + ephemeral: true, + }); +} diff --git a/src/lib/admin.ts b/src/lib/admin.ts new file mode 100644 index 0000000..1b203ef --- /dev/null +++ b/src/lib/admin.ts @@ -0,0 +1,40 @@ +import crypto from "crypto"; +import { and, eq } from "drizzle-orm"; + +import { db } from "../db"; +import { admins } from "../db/schema/admins"; + +export async function isAdmin(discordUserId: string) { + const res = await db.query.admins.findFirst({ + where: (table) => + and(eq(table.discordUserId, discordUserId), eq(table.isAdmin, true)), + }); + return !!res; +} + +export async function getAdminOtp(discordUserId: string) { + const res = await db.query.admins.findFirst({ + where: eq(admins.discordUserId, discordUserId), + }); + return res?.otp; +} + +export async function wantsAdmin(discordUserId: string) { + const otp = crypto.randomBytes(32).toString("hex"); + const [newAdmin] = await db + .insert(admins) + .values({ discordUserId, otp }) + .onConflictDoUpdate({ + target: [admins.discordUserId], + set: { otp }, + }) + .returning(); + return newAdmin; +} + +export async function approveAdmin(discordUserId: string) { + await db + .update(admins) + .set({ isAdmin: true }) + .where(eq(admins.discordUserId, discordUserId)); +}