"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 });
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 log = logging_1.getLogger("PgDatastore");
class PgDataStore {
    constructor(bridgeDomain, connectionString, pkeyPath, min = 1, max = 4) {
        this.bridgeDomain = bridgeDomain;
        this.serverMappings = {};
        this.hasEnded = false;
        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", (e) => {
            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 })),
            matrix_id: pgEntry.room_id,
            remote_id: "foobar",
            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));
    }
    getProvisionedMappings(roomId) {
        return bluebird_1.default.cast(this.pgPool.query("SELECT * FROM rooms WHERE room_id = $1 AND origin = 'provision'", [
            roomId
        ])).then((result) => result.rows).map((e) => PgDataStore.pgToRoomEntry(e));
    }
    async removeRoom(roomId, ircDomain, ircChannel, origin) {
        await this.pgPool.query("DELETE FROM rooms WHERE room_id = $1 AND irc_domain = $2 AND irc_channel = $3 AND origin = $4", [roomId, ircDomain, ircChannel, origin]);
    }
    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]);
        }
        return entries.rows.map((e) => {
            const server = this.serverMappings[e.irc_domain];
            if (!server) {
                // ! is used here because typescript doesn't understand the .filter
                return undefined;
            }
            return new IrcRoom_1.IrcRoom(server, e.irc_channel || e.irc_nick);
        }).filter((i) => i !== undefined);
    }
    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
            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, allowUnset) {
        if (!Array.isArray(origin)) {
            origin = [origin];
        }
        const inStatement = origin.map((_, i) => `\$${i + 3}`).join(", ");
        const statement = `SELECT * FROM rooms WHERE irc_domain = $1 AND irc_channel = $2 AND origin IN (${inStatement})`;
        const entries = await this.pgPool.query(statement, [
            server.domain,
            // Channels must be lowercase
            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
            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 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 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 ? res.rows[0].count : 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 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;
        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 = undefined;
        if (config.getPassword() && this.cryptoStore) {
            password = this.cryptoStore.encrypt(config.getPassword());
        }
        const parameters = {
            user_id: userId,
            domain: config.getDomain(),
            // either use the decrypted password, or whatever is stored already.
            password: password || config.getPassword(),
            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 pgRes = (await this.pgPool.query("SELECT features FROM user_features WHERE user_id = $1", [userId]));
        if (pgRes.rowCount === 0) {
            return {};
        }
        return pgRes.rows[0].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 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;
        }
        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 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 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}`);
            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 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 = 3;
//# sourceMappingURL=PgDataStore.js.map