diff --git a/src/channel-storage/channelstore.js b/src/channel-storage/channelstore.js index fa8a1678..5d29e121 100644 --- a/src/channel-storage/channelstore.js +++ b/src/channel-storage/channelstore.js @@ -1,4 +1,5 @@ import { FileStore } from './filestore'; +import { DatabaseStore } from './dbstore'; const CHANNEL_STORE = new FileStore(); diff --git a/src/channel-storage/dbstore.js b/src/channel-storage/dbstore.js new file mode 100644 index 00000000..c6fb5aa4 --- /dev/null +++ b/src/channel-storage/dbstore.js @@ -0,0 +1,83 @@ +import Promise from 'bluebird'; +import { ChannelStateSizeError, + ChannelDataNotFoundError } from '../errors'; +import db from '../database'; +import Logger from '../logger'; + +const SIZE_LIMIT = 1048576; +const QUERY_CHANNEL_ID_FOR_NAME = 'SELECT id FROM channels WHERE name = ?'; +const QUERY_CHANNEL_DATA = 'SELECT `key`, `value` FROM channel_data WHERE channel_id = ?'; +const QUERY_UPDATE_CHANNEL_DATA = + 'INSERT INTO channel_data VALUES (?, ?, ?) ON DUPLICATE KEY UPDATE `value` = ?'; + +function queryAsync(query, substitutions) { + return new Promise((resolve, reject) => { + db.query(query, substitutions, (err, res) => { + if (err) { + if (!(err instanceof Error)) { + err = new Error(err); + } + reject(err); + } else { + resolve(res); + } + }); + }); +} + +export class DatabaseStore { + load(channelName) { + return queryAsync(QUERY_CHANNEL_ID_FOR_NAME, [channelName]).then((rows) => { + if (rows.length === 0) { + throw new ChannelNotFoundError(`Channel does not exist: "${channelName}"`); + } + + return queryAsync(QUERY_CHANNEL_DATA, [rows[0].id]); + }).then(rows => { + const data = {}; + for (const row of rows) { + try { + data[row.key] = JSON.parse(row.value); + } catch (e) { + Logger.errlog.log(`Channel data for channel "${channelName}", ` + + `key "${row.key}" is invalid: ${e}`); + } + } + + return data; + }); + } + + save(channelName, data) { + return queryAsync(QUERY_CHANNEL_ID_FOR_NAME, [channelName]).then((rows) => { + if (rows.length === 0) { + throw new ChannelNotFoundError(`Channel does not exist: "${channelName}"`); + } + + let totalSize = 0; + const id = rows[0].id; + const substitutions = []; + for (const key of Object.keys(data)) { + const value = JSON.stringify(data[key]); + totalSize += value.length; + substitutions.push([ + id, + key, + value, + value // Extra substitution var necessary for ON DUPLICATE KEY UPDATE + ]); + } + + if (totalSize > SIZE_LIMIT) { + throw new ChannelStateSizeError('Channel state size is too large', { + limit: SIZE_LIMIT, + actual: totalSize + }); + } + + return Promise.map(substitutions, entry => { + return queryAsync(QUERY_UPDATE_CHANNEL_DATA, entry); + }); + }); + } +} diff --git a/src/database/tables.js b/src/database/tables.js index ab72cfd2..a5919d54 100644 --- a/src/database/tables.js +++ b/src/database/tables.js @@ -104,6 +104,15 @@ const TBL_BANS = "" + "INDEX (`ip`, `channel`), INDEX (`name`, `channel`)" + ") CHARACTER SET utf8"; +const TBL_CHANNEL_DATA = "" + + "CREATE TABLE IF NOT EXISTS `channel_data` (" + + "`channel_id` INT NOT NULL," + + "`key` VARCHAR(20) NOT NULL," + + "`value` TEXT CHARACTER SET utf8mb4 NOT NULL," + + "PRIMARY KEY (`channel_id`, `key`)," + + "FOREIGN KEY (`channel_id`) REFERENCES `channels`(`id`) ON DELETE CASCADE" + + ") CHARACTER SET utf8"; + module.exports.init = function (queryfn, cb) { var tables = { users: TBL_USERS, @@ -116,7 +125,8 @@ module.exports.init = function (queryfn, cb) { user_playlists: TBL_USER_PLAYLISTS, aliases: TBL_ALIASES, stats: TBL_STATS, - meta: TBL_META + meta: TBL_META, + channel_data: TBL_CHANNEL_DATA }; var AsyncQueue = require("../asyncqueue"); diff --git a/src/errors.js b/src/errors.js index ee3a9ce7..d8ea077b 100644 --- a/src/errors.js +++ b/src/errors.js @@ -1,3 +1,4 @@ import createError from 'create-error'; export const ChannelStateSizeError = createError('ChannelStateSizeError'); +export const ChannelNotFoundError = createError('ChannelNotFoundError');