import * as crypto from 'crypto'; import * as pg from 'pg'; import * as uuid from 'uuid'; import ConcurrentQueue from '../concurrent-queue/concurrent-queue'; import Logger from '../logger/logger'; const LOG = Logger.create('db'); const db = new pg.Client({ host: 'localhost', user: 'cordis', password: 'cordis_pass', database: 'cordis', }); export default class DB { static async connect(): Promise { await db.connect(); } static async end(): Promise { await db.end(); } private static TRANSACTION_QUEUE = new ConcurrentQueue(1); static async beginTransaction(): Promise { await db.query('BEGIN'); } static async rollbackTransaction(): Promise { await db.query('ROLLBACK'); } static async commitTransaction(): Promise { await db.query('COMMIT'); } static async queueTransaction(func): Promise { await DB.TRANSACTION_QUEUE.push(async () => { try { await this.beginTransaction(); await func(); await this.commitTransaction(); } catch (e) { await this.rollbackTransaction(); throw e; } }); } static async getAllGuilds(): Promise { let result = await db.query('SELECT * FROM "guilds" LEFT JOIN "guilds_meta" ON "guilds"."id"="guilds_meta"."id"'); return result.rows; } // Returns the member_id and guild_id for the member with specified public key static async getMemberInfo(publicKey: crypto.KeyObject): Promise { let der = publicKey.export({ type: 'spki', format: 'der' }); LOG.silly(`searching for public key (der hash: ${LOG.inspect(crypto.createHash('sha256').update(der).digest().toString('hex'))})`); let result = await db.query('SELECT "id" AS member_id, "guild_id" FROM "members" WHERE "public_key"=$1', [ der ]); if (result.rows.length != 1) { throw new Error('unable to find member with specified public key'); } return result.rows[0]; } static async getGuild(guildId: string): Promise { let result = await db.query('SELECT "name", "icon_resource_id" FROM "guilds_meta" WHERE "id"=$1', [ guildId ]); if (result.rows.length != 1) { throw new Error('unable to find guild with specified id'); } return result.rows[0]; } static async setName(guildId: string, name: string): Promise { let result = await db.query(` UPDATE "guilds_meta" SET "name"=$1 WHERE "id"=$2 RETURNING "name", "icon_resource_id" `, [ name, guildId ]); if (result.rows.length != 1) { throw new Error('unable to update guild name'); } return result.rows[0]; } static async setIcon(guildId: string, iconResourceId: string): Promise { let result = await db.query(` UPDATE "guilds_meta" SET "icon_resource_id"=$1 WHERE "id"=$2 RETURNING "name", "icon_resource_id" `, [ iconResourceId, guildId ]); if (result.rows.length != 1) { throw new Error('unable to update guild icon'); } return result.rows[0]; } static async getMembers(guildId: string): Promise { let result = await db.query('SELECT * FROM "members_with_roles" WHERE "guild_id"=$1', [ guildId ]); return result.rows; } static async getMember(guildId: string, memberId: string): Promise { let result = await db.query('SELECT * FROM "members_with_roles" WHERE "guild_id"=$1 AND "id"=$2', [ guildId, memberId ]); if (result.rows.length != 1) { throw new Error('unable to get member'); } return result.rows[0]; } static async getChannels(guildId: string): Promise { let result = await db.query('SELECT "id", "index", "name", "flavor_text" FROM "channels" WHERE "guild_id"=$1 ORDER BY "index"', [ guildId ]); return result.rows; } static async createChannel(guildId: string, name: string, flavorText: string): Promise { let channel = null; await DB.queueTransaction(async () => { let indexResult = await db.query('SELECT MAX("index") AS max_index FROM "channels" WHERE "guild_id"=$1', [ guildId ]); if (indexResult.rows.length != 1) { throw new Error('invalid index result'); } let newIndex = (indexResult.rows[0].max_index || 0) + 1; let insertResult = await db.query(` INSERT INTO "channels" ("guild_id", "index", "name", "flavor_text") VALUES ($1, $2, $3, $4) RETURNING "id", "index", "name", "flavor_text" `, [ guildId, newIndex, name, flavorText ]); if (insertResult.rows.length != 1) { throw new Error('unable to insert channel'); } channel = insertResult.rows[0]; }); return channel; } static async updateChannel(guildId: string, channelId: string, name: string, flavorText: string): Promise { let result = await db.query(` UPDATE "channels" SET "name"=$1, "flavor_text"=$2 WHERE "guild_id"=$3 AND "id"=$4 RETURNING * `, [ name, flavorText, guildId, channelId ]); if (result.rows.length != 1) { throw new Error('unable to update channel'); } return result.rows[0]; } static async getMessagesRecent(guildId: string, channelId: string, number: string): Promise { let result = await db.query(` SELECT * FROM ( SELECT "id", "channel_id", "member_id" ,"sent_dtg", "text" , "resource_id" , "resource_name" , "resource_width" , "resource_height" , "resource_preview_id" , "order" FROM "messages" WHERE "guild_id"=$1 AND "channel_id"=$2 ORDER BY "order" DESC LIMIT $3 ) AS "r" ORDER BY "r"."order" ASC`, [ guildId, channelId, number ]); return result.rows; } static async getMessagesBefore(guildId: string, channelId: string, messageId: string, number: number): Promise { let result = await db.query(` SELECT * FROM ( SELECT "id", "channel_id", "member_id" , "sent_dtg", "text" , "resource_id" , "resource_name" , "resource_width" , "resource_height" , "resource_preview_id" , "order" FROM "messages" WHERE "guild_id"=$1 AND "channel_id"=$2 AND "order" < (SELECT "order" FROM "messages" WHERE "id"=$3) ORDER BY "order" DESC LIMIT $4 ) AS "r" ORDER BY "r"."order" ASC`, [ guildId, channelId, messageId, number ]); return result.rows; } static async getMessagesAfter(guildId: string, channelId: string, messageId: string, number: number): Promise { let result = await db.query(` SELECT "id", "channel_id", "member_id" , "sent_dtg", "text" , "resource_id" , "resource_name" , "resource_width" , "resource_height" , "resource_preview_id" , "order" FROM "messages" WHERE "guild_id"=$1 AND "channel_id"=$2 AND "order" > (SELECT "order" FROM "messages" WHERE "id"=$3) ORDER BY "order" ASC, "id" ASC LIMIT $4`, [ guildId, channelId, messageId, number ]); return result.rows; } static async getResource(guildId: string, resourceId: string): Promise { let result = await db.query(` SELECT "id", "guild_id", "hash", "data" FROM "resources" WHERE "guild_id"=$1 AND "id"=$2 `, [ guildId, resourceId ]); if (result.rows.length != 1) { throw new Error(`unable to find specified resource g#${guildId}, r#${resourceId}`); } return result.rows[0]; } static async insertMessage(guildId: string, channelId: string, memberId: string, text: string): Promise { let id = uuid.v4(); let result = await db.query( `INSERT INTO "messages" ( "id", "guild_id", "channel_id", "member_id", "text", "sent_dtg", "order" ) VALUES ($1::UUID, $2, $3, $4, $5, NOW(), LPAD(FLOOR(EXTRACT(epoch FROM NOW())::decimal * 100000)::text, 20, '0') || $1::text) RETURNING "id", "channel_id", "member_id" , "text", "sent_dtg" , "resource_id" , "resource_name" , "resource_width" , "resource_height" , "resource_preview_id" , "order" `, [ id, guildId, channelId, memberId, text ]); if (result.rows.length != 1) { throw new Error('unable to properly insert message'); } return result.rows[0]; } static async insertMessageWithResource( guildId: string, channelId: string, memberId: string, text: string | null, resourceId: string, resourceName: string, resourceWidth: number | null, resourceHeight: number | null, resourcePreviewId: string | null ): Promise { let id = uuid.v4(); let result = await db.query( `INSERT INTO "messages" ( "id", "guild_id", "channel_id", "member_id", "text", "resource_id", "resource_name", "resource_width", "resource_height", "resource_preview_id", "sent_dtg", "order" ) VALUES ($1:UUID, $2, $3, $4, $5, $6, $7, $8, $9, $10, NOW(), LPAD(FLOOR(EXTRACT(epoch FROM NOW())::decimal * 100000)::text, 20, '0') || $1::text) RETURNING "id", "channel_id", "member_id" , "text", "sent_dtg" , "resource_id" , "resource_name" , "resource_width" , "resource_height" , "resource_preview_id" , "order" `, [ id, guildId, channelId, memberId, text, resourceId, resourceName, resourceWidth, resourceHeight, resourcePreviewId ]); if (result.rows.length != 1) { throw new Error('unable to properly insert message (with resource)'); } return result.rows[0]; } // Returns the resource id. Resources are deduped based on their hash static async insertResource(guildId: string, resourceBuff: Buffer): Promise { // HACK: using on conflict set guild_id=guild_id to ensure that RETURNING gives the proper id and we don't need another select let resourceResult = await db.query( `INSERT INTO "resources" ("guild_id", "hash", "data") VALUES ($1, digest($2::bytea, 'sha256'), $2::bytea) ON CONFLICT ("guild_id", "hash") DO UPDATE SET "guild_id"=EXCLUDED."guild_id" RETURNING "id"`, [ guildId, resourceBuff ]); if (resourceResult.rows.length != 1) { throw new Error('unable to insert resource'); } return resourceResult.rows[0].id; } // static async updateMessage( // guildId: string, // messageId: string, // channelId: string, // memberId: string, // text: string | null, // resourceId: string | null, // resourceName: string | null, // resourceWidth: number | null, // resourceHeight: number | null, // resourcePreviewId: string | null // ) { // let updateResult = await db.query( // `INSERT INTO "messages" ( // "guild_id", "channel_id", "member_id", "text", // "resource_id", "resource_name", "resource_width", "resource_height", // "resource_preview_id", "sent_dtg" // ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, NOW()) // WHERE // "guild_id"=$1 // AND "message_id"=$2 // ` // ) // } // Resets all members status to Offline for specified guildId static async clearAllMemberStatus(guildId: string): Promise { await db.query(`UPDATE "members" SET "status"='offline' WHERE "guild_id"=$1`, [ guildId ]); } // Set the status of a specified member static async setMemberStatus(guildId: string, memberId: string, status: string): Promise { let result = await db.query(` UPDATE "members" SET "status"=$1 WHERE "guild_id"=$2 AND "id"=$3 `, [ status, guildId, memberId ]); if (result.rowCount != 1) { throw new Error('unable to update status'); } } // Set the display name of a specified member static async setMemberDisplayName(guildId: string, memberId: string, displayName: string): Promise { let result = await db.query(` UPDATE "members" SET "display_name"=$1 WHERE "guild_id"=$2 AND "id"=$3 `, [ displayName, guildId, memberId ]); if (result.rowCount != 1) { throw new Error('unable to update status'); } } // Creates a role in the guild (returns the id) static async createRole(guildId: string, name: string, color: string, priority: number): Promise { let result = await db.query(` INSERT INTO "roles" ("guild_id", "name", "color", "priority") VALUES ($1, $2, $3, $4) RETURNING "id" `, [ guildId, name, color, priority ]); if (result.rows.length != 1) { throw new Error('unable to create role'); } return result.rows[0].id; } static async removeRole(guildId: string, roleId: string): Promise { let result = await db.query( `DELETE FROM "roles" WHERE "id"=$1 AND "guild_id"=$2`, [ roleId, guildId ] ); if (result.rowCount != 1) { throw new Error('unable to remove role'); } } static async assignRoleToMember(guildId: string, roleId: string, memberId: string): Promise { let existsResult = await db.query(` SELECT COUNT(*) AS c FROM "member_roles" WHERE "role_id" = $1 AND "guild_id" = $2 AND "member_id" = $3 `, [ roleId, guildId, memberId ]); if (existsResult.rows.length != 1) { throw new Error('unable to check for existing privilege'); } if (existsResult.rows[0].c != 0) { LOG.warn(`r#${roleId} already assigned to u#${memberId} in g#${guildId}`); return; } let result = await db.query(` INSERT INTO "member_roles" ("role_id", "guild_id", "member_id") VALUES ($1, $2, $3) RETURNING "role_id" `, [ roleId, guildId, memberId ]); if (result.rows.length != 1) { throw new Error('unable to assign user to role'); } } static async revokeRoleFromMember(guildId: string, roleId: string, memberId: string): Promise { let existsResult = await db.query(` SELECT COUNT(*) AS c FROM "member_roles" WHERE "role_id" = $1 AND "guild_id" = $2 AND "member_id" = $3 `, [ roleId, guildId, memberId ]); if (existsResult.rows.length != 1) { throw new Error('unable to check for existing privilege'); } if (existsResult.rows[0].c == 0) { LOG.warn(`r#${roleId} was never assigned to u#${memberId} in g#${guildId}`); return; } let result = await db.query(` DELETE FROM "member_roles" WHERE "role_id"=$1 AND "guild_id"=$2 AND "member_id"=$3 `, [ roleId, guildId, memberId ]); if (result.rowCount != 1) { throw new Error('unable to revoke role from user'); } } static async assignRolePrivilege(guildId: string, roleId: string, privilege: string): Promise { let existsResult = await db.query(` SELECT COUNT(*) AS c FROM "role_privileges" WHERE "role_id" = $1 AND "guild_id" = $2 AND "privilege" = $3 `, [ roleId, guildId, privilege ]); if (existsResult.rows.length != 1) { throw new Error('unable to check for existing privilege'); } if (existsResult.rows[0].c != 0) { LOG.warn(`privilege ${privilege} already exists for r#${roleId} on g#${guildId}`); return; } let result = await db.query(` INSERT INTO "role_privileges" ("role_id", "guild_id", "privilege") VALUES ($1, $2, $3) RETURNING "role_id" `, [ roleId, guildId, privilege ]); if (result.rows.length != 1) { throw new Error('unable to add privilege to role privileges'); } } static async revokeRolePrivilege(roleId: string, guildId: string, privilege: string): Promise { let existsResult = await db.query(` SELECT COUNT(*) AS c FROM "role_privileges" WHERE "role_id" = $1 AND "guild_id" = $2 AND "privilege" = $3 `, [ roleId, guildId, privilege ]); if (existsResult.rows.length != 1) { throw new Error('unable to check for existing privilege'); } if (existsResult.rows[0].c == 0) { LOG.warn(`privilege ${privilege} did not exist for r#${roleId} on g#${guildId}`); return; } let result = await db.query(` DELETE FROM "role_privileges" WHERE "role_id"=$1 , "guild_id"=$2 , "privilege"=$3 `, [ roleId, guildId, privilege ]); if (result.rowCount != 1) { throw new Error('unable to revoke privilege to role privileges'); } } static async hasPrivilege(guildId: string, memberId: string, privilege: string): Promise { let result = await db.query(` SELECT COUNT(*) AS c FROM member_roles , role_privileges WHERE member_roles.role_id=role_privileges.role_id AND member_roles.guild_id=$1 AND member_roles.member_id=$2 AND role_privileges.privilege=$3 `, [ guildId, memberId, privilege ]); if (result.rows.length != 1) { throw new Error('unable to check for privilege'); } return result.rows[0].c > 0; } static async setMemberAvatarResourceId(guildId: string, memberId: string, avatarResourceId: string): Promise { let result = await db.query(` UPDATE "members" SET "avatar_resource_id"=$1 WHERE "guild_id"=$2 AND "id"=$3 `, [ avatarResourceId, guildId, memberId ]); if (result.rowCount != 1) { throw new Error('unable to update status'); } } static async getTokens(guildId: string): Promise { let result = await db.query(` SELECT "token", "member_id", "created", "expires" FROM tokens WHERE "guild_id"=$1 `, [ guildId]); return result.rows; } //insert into tokens (guild_id, expires) VALUES ('226b3e9e-5220-4205-bf5b-6738b9b3bb39', NOW() + '7 days'::interval) RETURNING "token", "expires"; static async createToken(guildId: string, expiresAfter: string): Promise { let result = await db.query(` INSERT INTO "tokens" ("guild_id", "expires") VALUES ($1, NOW() + $2::interval) RETURNING "token", "expires" `, [ guildId, expiresAfter ]); if (result.rows.length != 1) { throw new Error('unable to insert a token'); } return result.rows[0]; } static async isTokenReal(token: string): Promise { let result = await db.query(`SELECT COUNT(*) AS c FROM "tokens" WHERE "token"=$1`, [ token ]); return result.rows[0].c == 1; } static async isTokenForGuild(token: string, guildId: string): Promise { let result = await db.query(`SELECT COUNT(*) AS c FROM "tokens" WHERE "token"=$1 AND "guild_id"=$2`, [ token, guildId ]); return result.rows[0].c == 1; } static async isTokenTaken(token: string): Promise { let result = await db.query(`SELECT COUNT(*) AS c FROM "tokens" WHERE "token"=$1 AND "member_id" IS NOT NULL`, [ token ]); return result.rows[0].c == 1; } static async isTokenActive(token: string): Promise { let result = await db.query(`SELECT COUNT(*) AS c FROM "tokens" WHERE "token"=$1 AND "member_id" IS NULL AND "expires">NOW()`, [ token ]); return result.rows[0].c == 1; } // registers a user for with a token // NOTE: Tokens are unique across guilds so they can be used to get the guildId static async registerWithToken(token: string, derBuff: Buffer, displayName: string, avatarBuff: Buffer): Promise<{ guildId: string, memberId: string }> { // insert avatar as resource let result: { memberId: string, guildId: string } | null = null; await DB.queueTransaction(async () => { let resultguildId = await db.query(` SELECT "guild_id" FROM "tokens" WHERE "token"=$1 `, [ token ]); if (resultguildId.rows.length != 1) { throw new Error('unable to get token guild id'); } let guildId = resultguildId.rows[0].guild_id as string; let avatarResourceId = await DB.insertResource(guildId, avatarBuff); let resultInsertMember = await db.query(` INSERT INTO "members" ( "guild_id", "public_key", "display_name", "avatar_resource_id" ) VALUES ($1, $2, $3, $4) RETURNING "id" `, [ guildId, derBuff, displayName, avatarResourceId ]); if (resultInsertMember.rows.length != 1) { throw new Error('unable to insert member'); } let memberId = resultInsertMember.rows[0].id; let resultUpdate = await db.query(` UPDATE "tokens" SET "member_id"=$1 WHERE "token"=$2 `, [ memberId, token ]); if (resultUpdate.rowCount != 1) { throw new Error('unable to update token with new member'); } result = { guildId, memberId }; }); if (!result) { throw new Error('result was not set'); } return result; } static async revokeToken(token: string): Promise { let result = await db.query('DELETE FROM "tokens" WHERE "token"=$1', [ token ]); if (result.rowCount != 1) { throw new Error('unable to remove token'); } } }