"use strict";
/*
Copyright 2019 The Matrix.org Foundation C.I.C.

Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at

    http://www.apache.org/licenses/LICENSE-2.0

Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
var __importDefault = (this && this.__importDefault) || function (mod) {
    return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.PgDataStore = void 0;
const pg_1 = require("pg");
const matrix_appservice_bridge_1 = require("matrix-appservice-bridge");
const IrcRoom_1 = require("../../models/IrcRoom");
const IrcClientConfig_1 = require("../../models/IrcClientConfig");
const logging_1 = require("../../logging");
const bluebird_1 = __importDefault(require("bluebird"));
const StringCrypto_1 = require("../StringCrypto");
const formatting_1 = require("../../irc/formatting");
const NedbDataStore_1 = require("../NedbDataStore");
const quick_lru_1 = __importDefault(require("quick-lru"));
const log = (0, logging_1.getLogger)("PgDatastore");
const FEATURE_CACHE_SIZE = 512;
class PgDataStore {
    constructor(bridgeDomain, connectionString, pkeyPath, min = 1, max = 4) {
        this.bridgeDomain = bridgeDomain;
        this.serverMappings = {};
        this.hasEnded = false;
        this.userFeatureCache = new quick_lru_1.default({
            maxSize: FEATURE_CACHE_SIZE,
        });
        this.pgPool = new pg_1.Pool({
            connectionString,
            min,
            max,
        });
        this.pgPool.on("error", (err) => {
            log.error("Postgres Error: %s", err);
        });
        if (pkeyPath) {
            this.cryptoStore = new StringCrypto_1.StringCrypto();
            this.cryptoStore.load(pkeyPath);
        }
        process.on("beforeExit", () => {
            if (this.hasEnded) {
                return;
            }
            // Ensure we clean up on exit
            this.pgPool.end();
        });
    }
    async setServerFromConfig(server, serverConfig) {
        this.serverMappings[server.domain] = server;
        for (const channel of Object.keys(serverConfig.mappings)) {
            const ircRoom = new IrcRoom_1.IrcRoom(server, channel);
            ircRoom.set("type", "channel");
            for (const roomId of serverConfig.mappings[channel].roomIds) {
                const mxRoom = new matrix_appservice_bridge_1.MatrixRoom(roomId);
                await this.storeRoom(ircRoom, mxRoom, "config");
            }
        }
    }
    async storeRoom(ircRoom, matrixRoom, origin) {
        if (typeof origin !== "string") {
            throw new Error('Origin must be a string = "config"|"provision"|"alias"|"join"');
        }
        log.info("storeRoom (id=%s, addr=%s, chan=%s, origin=%s, type=%s)", matrixRoom.getId(), ircRoom.getDomain(), ircRoom.channel, origin, ircRoom.getType());
        // We need to *clone* this as we are about to be evil.
        const ircRoomSerial = JSON.parse(JSON.stringify(ircRoom.serialize()));
        // These keys do not need to be stored inside the JSON blob as we store them
        // inside dedicated columns. They will be reinserted into the JSON blob
        // when fetched.
        const type = ircRoom.getType();
        const domain = ircRoom.getDomain();
        const channel = ircRoom.getChannel();
        delete ircRoomSerial.domain;
        delete ircRoomSerial.channel;
        delete ircRoomSerial.type;
        await this.upsertRoom(origin, type, domain, channel, matrixRoom.getId(), JSON.stringify(ircRoomSerial), JSON.stringify(matrixRoom.serialize()));
    }
    async upsertRoom(origin, type, domain, channel, roomId, ircJson, matrixJson) {
        const parameters = {
            origin,
            type,
            irc_domain: domain,
            irc_channel: channel,
            room_id: roomId,
            irc_json: ircJson,
            matrix_json: matrixJson,
        };
        const statement = PgDataStore.BuildUpsertStatement("rooms", "ON CONSTRAINT cons_rooms_unique", Object.keys(parameters));
        await this.pgPool.query(statement, Object.values(parameters));
    }
    static pgToRoomEntry(pgEntry) {
        return {
            id: NedbDataStore_1.NeDBDataStore.createMappingId(pgEntry.room_id, pgEntry.irc_domain, pgEntry.irc_channel),
            matrix: new matrix_appservice_bridge_1.MatrixRoom(pgEntry.room_id, pgEntry.matrix_json),
            remote: new matrix_appservice_bridge_1.RemoteRoom("", Object.assign(Object.assign({}, pgEntry.irc_json), { channel: pgEntry.irc_channel, domain: pgEntry.irc_domain, type: pgEntry.type })),
            data: {
                origin: pgEntry.origin,
            },
        };
    }
    async getRoom(roomId, ircDomain, ircChannel, origin) {
        let statement = "SELECT * FROM rooms WHERE room_id = $1 AND irc_domain = $2 AND irc_channel = $3";
        let params = [roomId, ircDomain, ircChannel];
        if (origin) {
            statement += " AND origin = $4";
            params = params.concat(origin);
        }
        const pgEntry = await this.pgPool.query(statement, params);
        if (!pgEntry.rowCount) {
            return null;
        }
        return PgDataStore.pgToRoomEntry(pgEntry.rows[0]);
    }
    async getAllChannelMappings() {
        const entries = (await this.pgPool.query("SELECT irc_domain, room_id, irc_channel FROM rooms WHERE type = 'channel'")).rows;
        const mappings = {};
        const validDomains = Object.keys(this.serverMappings);
        entries.forEach((e) => {
            if (!e.room_id) {
                return;
            }
            // Filter out servers we don't know about
            if (!validDomains.includes(e.irc_domain)) {
                return;
            }
            if (!mappings[e.room_id]) {
                mappings[e.room_id] = [];
            }
            mappings[e.room_id].push({
                networkId: this.serverMappings[e.irc_domain].getNetworkId(),
                channel: e.irc_channel,
            });
        });
        return mappings;
    }
    getEntriesByMatrixId(roomId) {
        return bluebird_1.default.cast(this.pgPool.query("SELECT * FROM rooms WHERE room_id = $1", [
            roomId
        ])).then((result) => result.rows).map((e) => PgDataStore.pgToRoomEntry(e));
    }
    async getProvisionedMappings(roomId) {
        const res = await this.pgPool.query("SELECT * FROM rooms WHERE room_id = $1 AND origin = 'provision'", [
            roomId
        ]).then((result) => result.rows);
        return res.map((e) => PgDataStore.pgToRoomEntry(e));
    }
    async removeRoom(roomId, ircDomain, ircChannel, origin) {
        let statement = "DELETE FROM rooms WHERE room_id = $1 AND irc_domain = $2 AND irc_channel = $3";
        let params = [roomId, ircDomain, ircChannel];
        if (origin) {
            statement += " AND origin = $4";
            params = params.concat(origin);
        }
        await this.pgPool.query(statement, params);
    }
    async getIrcChannelsForRoomId(roomId) {
        let entries = await this.pgPool.query("SELECT irc_domain, irc_channel FROM rooms WHERE room_id = $1", [roomId]);
        if (entries.rowCount === 0) {
            // Could be a PM room, if it's not a channel.
            entries = await this.pgPool.query("SELECT irc_domain, irc_nick FROM pm_rooms WHERE room_id = $1", [roomId]);
        }
        const rooms = [];
        for (const row of entries.rows) {
            const server = this.serverMappings[row.irc_domain];
            if (server) {
                rooms.push(new IrcRoom_1.IrcRoom(server, row.irc_channel || row.irc_nick));
            }
        }
        return rooms;
    }
    async getIrcChannelsForRoomIds(roomIds) {
        const entries = await this.pgPool.query("SELECT room_id, irc_domain, irc_channel FROM rooms WHERE room_id IN $1", [roomIds]);
        const mapping = {};
        entries.rows.forEach((e) => {
            const server = this.serverMappings[e.irc_domain];
            if (!server) {
                // ! is used here because typescript doesn't understand the .filter
                return;
            }
            if (!mapping[e.room_id]) {
                mapping[e.room_id] = [];
            }
            mapping[e.room_id].push(new IrcRoom_1.IrcRoom(server, e.irc_channel));
        });
        return mapping;
    }
    async getMatrixRoomsForChannel(server, channel) {
        const entries = await this.pgPool.query("SELECT room_id, matrix_json FROM rooms WHERE irc_domain = $1 AND irc_channel = $2", [
            server.domain,
            // Channels must be lowercase
            (0, formatting_1.toIrcLowerCase)(channel),
        ]);
        return entries.rows.map((e) => new matrix_appservice_bridge_1.MatrixRoom(e.room_id, e.matrix_json));
    }
    async getMappingsForChannelByOrigin(server, channel, origin) {
        if (!Array.isArray(origin)) {
            origin = [origin];
        }
        const inStatement = origin.map((_, i) => `\$${i + 3}`).join(", ");
        const entries = await this.pgPool.query(`SELECT * FROM rooms WHERE irc_domain = $1 AND irc_channel = $2 AND origin IN (${inStatement})`, [
            server.domain,
            // Channels must be lowercase
            (0, formatting_1.toIrcLowerCase)(channel),
        ].concat(origin));
        return entries.rows.map((e) => PgDataStore.pgToRoomEntry(e));
    }
    async getModesForChannel(server, channel) {
        log.debug(`Getting modes for ${server.domain} ${channel}`);
        const mapping = {};
        const entries = await this.pgPool.query("SELECT room_id, irc_json->>'modes' AS modes FROM rooms " +
            "WHERE irc_domain = $1 AND irc_channel = $2", [
            server.domain,
            // Channels must be lowercase
            (0, formatting_1.toIrcLowerCase)(channel),
        ]);
        entries.rows.forEach((e) => {
            mapping[e.room_id] = e.modes || [];
        });
        return mapping;
    }
    async setModeForRoom(roomId, mode, enabled) {
        log.info("setModeForRoom (mode=%s, roomId=%s, enabled=%s)", mode, roomId, enabled);
        const entries = await this.getEntriesByMatrixId(roomId);
        for (const entry of entries) {
            if (!entry.remote) {
                continue;
            }
            const modes = entry.remote.get("modes") || [];
            const hasMode = modes.includes(mode);
            if (hasMode === enabled) {
                continue;
            }
            if (enabled) {
                modes.push(mode);
            }
            else {
                modes.splice(modes.indexOf(mode), 1);
            }
            entry.remote.set("modes", modes);
            // Clone the object
            const ircRoomSerial = JSON.parse(JSON.stringify(entry.remote.serialize()));
            delete ircRoomSerial.domain;
            delete ircRoomSerial.channel;
            delete ircRoomSerial.type;
            await this.pgPool.query("UPDATE rooms SET irc_json = $4 WHERE room_id = $1 AND irc_channel = $2 AND irc_domain = $3", [
                roomId,
                entry.remote.get("channel"),
                entry.remote.get("domain"),
                JSON.stringify(ircRoomSerial),
            ]);
        }
    }
    async setPmRoom(ircRoom, matrixRoom, userId, virtualUserId) {
        log.debug(`setPmRoom (matrix_user_id=${userId}, virtual_user_id=${virtualUserId}, ` +
            `room_id=${matrixRoom.getId()}, irc_nick=${ircRoom.getChannel()})`);
        await this.pgPool.query(PgDataStore.BuildUpsertStatement("pm_rooms", "ON CONSTRAINT cons_pm_rooms_matrix_irc_unique", [
            "room_id",
            "irc_domain",
            "irc_nick",
            "matrix_user_id",
            "virtual_user_id",
        ]), [
            matrixRoom.getId(),
            ircRoom.getDomain(),
            ircRoom.getChannel(),
            userId,
            virtualUserId,
        ]);
    }
    async removePmRoom(roomId) {
        log.debug(`removePmRoom (room_id=${roomId}`);
        await this.pgPool.query("DELETE FROM pm_rooms WHERE room_id = $1", [roomId]);
    }
    async getMatrixPmRoom(realUserId, virtualUserId) {
        log.debug(`getMatrixPmRoom (matrix_user_id=${realUserId}, virtual_user_id=${virtualUserId})`);
        const res = await this.pgPool.query("SELECT room_id FROM pm_rooms WHERE matrix_user_id = $1 AND virtual_user_id = $2", [
            realUserId,
            virtualUserId,
        ]);
        if (res.rowCount === 0) {
            return null;
        }
        return new matrix_appservice_bridge_1.MatrixRoom(res.rows[0].room_id);
    }
    async getMatrixPmRoomById(roomId) {
        log.debug(`getMatrixPmRoom (roomId=${roomId})`);
        const res = await this.pgPool.query("SELECT room_id, matrix_user_id, virtual_user_id FROM pm_rooms WHERE room_id = $1", [
            roomId,
        ]);
        if (res.rowCount === 0) {
            return null;
        }
        return new matrix_appservice_bridge_1.MatrixRoom(res.rows[0].room_id);
    }
    async getTrackedChannelsForServer(domain) {
        if (!this.serverMappings[domain]) {
            // Return empty if we don't know the server.
            return [];
        }
        log.info(`Fetching all channels for ${domain}`);
        const chanSet = await this.pgPool.query("SELECT DISTINCT irc_channel FROM rooms WHERE irc_domain = $1", [domain]);
        return chanSet.rows.map((e) => e.irc_channel);
    }
    async getRoomIdsFromConfig() {
        return (await this.pgPool.query("SELECT room_id FROM rooms WHERE origin = 'config'")).rows.map((e) => e.room_id);
    }
    async removeConfigMappings() {
        await this.pgPool.query("DELETE FROM rooms WHERE origin = 'config'");
    }
    async getIpv6Counter() {
        const res = await this.pgPool.query("SELECT count FROM ipv6_counter");
        return res ? parseInt(res.rows[0].count, 10) : 0;
    }
    async setIpv6Counter(counter) {
        await this.pgPool.query("UPDATE ipv6_counter SET count = $1", [counter]);
    }
    async upsertMatrixRoom(room) {
        // XXX: This is an upsert operation, but we don't have enough details to go on
        // so this will just update a rooms data entry. We only use this call to update
        // topics on an existing room.
        await this.pgPool.query("UPDATE rooms SET matrix_json = $1 WHERE room_id = $2", [
            JSON.stringify(room.serialize()),
            room.getId(),
        ]);
    }
    async getAdminRoomById(roomId) {
        const res = await this.pgPool.query("SELECT room_id FROM admin_rooms WHERE room_id = $1", [roomId]);
        if (res.rowCount === 0) {
            return null;
        }
        return new matrix_appservice_bridge_1.MatrixRoom(roomId);
    }
    async storeAdminRoom(room, userId) {
        await this.pgPool.query(PgDataStore.BuildUpsertStatement("admin_rooms", "(room_id)", [
            "room_id",
            "user_id",
        ]), [room.getId(), userId]);
    }
    async getAdminRoomByUserId(userId) {
        const res = await this.pgPool.query("SELECT room_id FROM admin_rooms WHERE user_id = $1", [userId]);
        if (res.rowCount === 0) {
            return null;
        }
        return new matrix_appservice_bridge_1.MatrixRoom(res.rows[0].room_id);
    }
    async removeAdminRoom(room) {
        await this.pgPool.query("DELETE FROM admin_rooms WHERE room_id = $1", [room.roomId]);
    }
    async storeMatrixUser(matrixUser) {
        const parameters = {
            user_id: matrixUser.getId(),
            data: JSON.stringify(matrixUser.serialize()),
        };
        const statement = PgDataStore.BuildUpsertStatement("matrix_users", "(user_id)", Object.keys(parameters));
        await this.pgPool.query(statement, Object.values(parameters));
    }
    async getIrcClientConfig(userId, domain) {
        const res = await this.pgPool.query("SELECT config, password FROM client_config WHERE user_id = $1 and domain = $2", [
            userId,
            domain
        ]);
        if (res.rowCount === 0) {
            return null;
        }
        const row = res.rows[0];
        const config = row.config || {}; // This may not be defined.
        if (row.password && this.cryptoStore) {
            config.password = this.cryptoStore.decrypt(row.password);
        }
        return new IrcClientConfig_1.IrcClientConfig(userId, domain, config);
    }
    async storeIrcClientConfig(config) {
        const userId = config.getUserId();
        if (!userId) {
            throw Error("IrcClientConfig does not contain a userId");
        }
        log.debug(`Storing client configuration for ${userId}`);
        // We need to make sure we have a matrix user in the store.
        await this.pgPool.query("INSERT INTO matrix_users VALUES ($1, NULL) ON CONFLICT DO NOTHING", [userId]);
        let password = config.getPassword();
        if (password && this.cryptoStore) {
            password = this.cryptoStore.encrypt(password);
        }
        const parameters = {
            user_id: userId,
            domain: config.getDomain(),
            // either use the decrypted password, or whatever is stored already.
            password,
            config: JSON.stringify(config.serialize(true)),
        };
        const statement = PgDataStore.BuildUpsertStatement("client_config", "ON CONSTRAINT cons_client_config_unique", Object.keys(parameters));
        await this.pgPool.query(statement, Object.values(parameters));
    }
    async getMatrixUserByLocalpart(localpart) {
        const res = await this.pgPool.query("SELECT user_id, data FROM matrix_users WHERE user_id = $1", [
            `@${localpart}:${this.bridgeDomain}`,
        ]);
        if (res.rowCount === 0) {
            return null;
        }
        const row = res.rows[0];
        return new matrix_appservice_bridge_1.MatrixUser(row.user_id, row.data);
    }
    async getUserFeatures(userId) {
        const existing = this.userFeatureCache.get(userId);
        if (existing) {
            return existing;
        }
        const pgRes = await this.pgPool.query("SELECT features FROM user_features WHERE user_id = $1", [userId]);
        const features = (pgRes.rows[0] || {});
        this.userFeatureCache.set(userId, features);
        return features;
    }
    async storeUserFeatures(userId, features) {
        const statement = PgDataStore.BuildUpsertStatement("user_features", "(user_id)", [
            "user_id",
            "features",
        ]);
        await this.pgPool.query(statement, [userId, JSON.stringify(features)]);
    }
    async getUserActivity() {
        const res = await this.pgPool.query('SELECT * FROM user_activity');
        const users = {};
        for (const row of res.rows) {
            users[row['user_id']] = row['data'];
        }
        return { users };
    }
    async storeUserActivity(userId, activity) {
        const stmt = PgDataStore.BuildUpsertStatement('user_activity', '(user_id)', ['user_id', 'data']);
        await this.pgPool.query(stmt, [userId, JSON.stringify(activity)]);
    }
    async storePass(userId, domain, pass, encrypt = true) {
        let password = pass;
        if (encrypt) {
            if (!this.cryptoStore) {
                throw Error("Password encryption is not configured.");
            }
            password = this.cryptoStore.encrypt(pass);
        }
        const parameters = {
            user_id: userId,
            domain,
            password,
        };
        const statement = PgDataStore.BuildUpsertStatement("client_config", "ON CONSTRAINT cons_client_config_unique", Object.keys(parameters));
        await this.pgPool.query(statement, Object.values(parameters));
    }
    async removePass(userId, domain) {
        await this.pgPool.query("UPDATE client_config SET password = NULL WHERE user_id = $1 AND domain = $2", [userId, domain]);
    }
    async getMatrixUserByUsername(domain, username) {
        // This will need a join
        const res = await this.pgPool.query("SELECT client_config.user_id, matrix_users.data FROM client_config, matrix_users " +
            "WHERE config->>'username' = $1 AND domain = $2 AND client_config.user_id = matrix_users.user_id", [username, domain]);
        if (res.rowCount === 0) {
            return undefined;
        }
        else if (res.rowCount > 1) {
            log.error("getMatrixUserByUsername returned %s results for %s on %s", res.rowCount, username, domain);
        }
        return new matrix_appservice_bridge_1.MatrixUser(res.rows[0].user_id, res.rows[0].data);
    }
    async getCountForUsernamePrefix(domain, usernamePrefix) {
        const res = await this.pgPool.query("SELECT COUNT(*) FROM client_config " +
            "WHERE domain = $2 AND config->>'username' LIKE $1 || '%'", [usernamePrefix, domain]);
        const count = parseInt(res.rows[0].count, 10);
        return count;
    }
    async roomUpgradeOnRoomMigrated(oldRoomId, newRoomId) {
        await this.pgPool.query("UPDATE rooms SET room_id = $1 WHERE room_id = $2", [newRoomId, oldRoomId]);
    }
    async updateLastSeenTimeForUser(userId) {
        const statement = PgDataStore.BuildUpsertStatement("last_seen", "(user_id)", [
            "user_id",
            "ts",
        ]);
        await this.pgPool.query(statement, [userId, Date.now()]);
    }
    async getLastSeenTimeForUsers() {
        const res = await this.pgPool.query(`SELECT * FROM last_seen`);
        return res.rows;
    }
    async getAllUserIds() {
        const res = await this.pgPool.query(`SELECT user_id FROM matrix_users`);
        return res.rows.map((u) => u.user_id);
    }
    async getRoomsVisibility(roomIds) {
        const map = {};
        const list = `('${roomIds.join("','")}')`;
        const res = await this.pgPool.query(`SELECT room_id, visibility FROM room_visibility WHERE room_id IN ${list}`);
        for (const row of res.rows) {
            map[row.room_id] = row.visibility ? "public" : "private";
        }
        return map;
    }
    async setRoomVisibility(roomId, visibility) {
        const statement = PgDataStore.BuildUpsertStatement("room_visibility", "(room_id)", [
            "room_id",
            "visibility",
        ]);
        await this.pgPool.query(statement, [roomId, visibility === "public"]);
        log.info(`setRoomVisibility ${roomId} => ${visibility}`);
    }
    async isUserDeactivated(userId) {
        const res = await this.pgPool.query(`SELECT user_id FROM deactivated_users WHERE user_id = $1`, [userId]);
        return res.rowCount > 0;
    }
    async deactivateUser(userId) {
        await this.pgPool.query("INSERT INTO deactivated_users VALUES ($1, $2)", [userId, Date.now()]);
    }
    async ensureSchema() {
        log.info("Starting postgres database engine");
        let currentVersion = await this.getSchemaVersion();
        while (currentVersion < PgDataStore.LATEST_SCHEMA) {
            log.info(`Updating schema to v${currentVersion + 1}`);
            // eslint-disable-next-line @typescript-eslint/no-var-requires
            const runSchema = require(`./schema/v${currentVersion + 1}`).runSchema;
            try {
                await runSchema(this.pgPool);
                currentVersion++;
                await this.updateSchemaVersion(currentVersion);
            }
            catch (ex) {
                log.warn(`Failed to run schema v${currentVersion + 1}:`, ex);
                throw Error("Failed to update database schema");
            }
        }
        log.info(`Database schema is at version v${currentVersion}`);
    }
    async getRoomCount() {
        const res = await this.pgPool.query(`SELECT COUNT(*) FROM rooms`);
        return res.rows[0];
    }
    async destroy() {
        log.info("Destroy called");
        if (this.hasEnded) {
            // No-op if end has already been called.
            return;
        }
        this.hasEnded = true;
        await this.pgPool.end();
        log.info("PostgresSQL connection ended");
        // This will no-op
    }
    async updateSchemaVersion(version) {
        log.debug(`updateSchemaVersion: ${version}`);
        await this.pgPool.query("UPDATE schema SET version = $1;", [version]);
    }
    async getSchemaVersion() {
        try {
            const { rows } = await this.pgPool.query("SELECT version FROM SCHEMA");
            return rows[0].version;
        }
        catch (ex) {
            if (ex.code === "42P01") { // undefined_table
                log.warn("Schema table could not be found");
                return 0;
            }
            log.error("Failed to get schema version: %s", ex);
        }
        throw Error("Couldn't fetch schema version");
    }
    static BuildUpsertStatement(table, constraint, keyNames) {
        const keys = keyNames.join(", ");
        const keysValues = `\$${keyNames.map((k, i) => i + 1).join(", $")}`;
        const keysSets = keyNames.map((k, i) => `${k} = \$${i + 1}`).join(", ");
        const statement = `INSERT INTO ${table} (${keys}) VALUES (${keysValues}) ` +
            `ON CONFLICT ${constraint} DO UPDATE SET ${keysSets}`;
        return statement;
    }
}
exports.PgDataStore = PgDataStore;
PgDataStore.LATEST_SCHEMA = 7;
//# sourceMappingURL=PgDataStore.js.map