diff --git a/server/api/index.js b/server/api/index.js index 7f8a547..1452e4c 100644 --- a/server/api/index.js +++ b/server/api/index.js @@ -31,14 +31,11 @@ class APIMethods { info (con, req, cb) { if (!req.fields) req.fields = "*"; if (!req.fieldsExtSource) req.fieldsExtSource = "*"; - const cache = this.info.cache[ - JSON.stringify({ - f: req.fields, e: req.fieldsExtSource - }) - ]; + const cacheKey = `${req.fields}\u0000${req.fieldsExtSource}`; + + const cache = this.info.cache[cacheKey]; if (cache) { - console.debug("CACHE!"); return cb({ result: cache, trace_id: req.trace_id, ended: true }); } @@ -55,11 +52,7 @@ class APIMethods { if (!fields[k]) serverInfo.extSource[k] = undefined; }); } - this.info.cache[ - JSON.stringify({ - f: req.fields, e: req.fieldsExtSource - }) - ] = serverInfo; + this.info.cache[cacheKey] = serverInfo; cb({ result: serverInfo, trace_id: req.trace_id, ended: true }); } @@ -104,6 +97,35 @@ class APIMethods { }); } + async characters (con, req, cb) { + const authedMethod = this.authed; + const isAuthed = await (new Promise((rs, rj) => { + authedMethod(con, req, ({ result, ended }) => { + if (ended) + return rs(result); + }); + })); + + if (!isAuthed) { + return cb({ + error: "require authorize", + trace_id: req.trace_id, + ended: true + }); + } + + // getting characters + const user = global.authed.get(con); + const result = (await user.getCharacters()) + .map(character => { + return character; + }); + + cb({ + result, trace_id: req.trace_id, ended: true + }); + } + /*setSession (isEncrypted, address, { key, counter }, cb) { if (!global.config.server.secureMode) return cb({ error: "Encryption is off by configuration file into server" }); diff --git a/server/api/utils/gen-token.js b/server/api/utils/gen-token.js index cc337a8..2fc3498 100644 --- a/server/api/utils/gen-token.js +++ b/server/api/utils/gen-token.js @@ -13,7 +13,7 @@ module.exports.registerToken = function (user) { token = module.exports(); } while (global.authed.tokens[token] !== undefined); global.authed.tokens[token] = user; - // (!) Потом добавить регистрацию в БД! + // TODO: Потом добавить регистрацию в БД! return token; }; diff --git a/server/api/utils/user.js b/server/api/utils/user.js index c9cc693..a516954 100644 --- a/server/api/utils/user.js +++ b/server/api/utils/user.js @@ -9,26 +9,46 @@ * * 5 - root */ -class User { - constructor(name, accessLevel) { - if ( - typeof accessLevel !== "number" || - { - "-1": true, - 0: true, - 1: true, - 2: true, - 3: true, - 4: true, - 5: true, - }["" + accessLevel] !== true - ) { +const ACCESS_LEVELS = { + "-1": "banned", + 0: "user", + 1: "helper", + 2: "moderator", + 3: "admin", + 4: "superadmin", + 5: "root", +}; + +class AccessLevel { + static validate (value) { + const access = Object.fromEntries(Object.keys(ACCESS_LEVELS) + .map(level => [level, true])); + return ( + Number.isInteger(value) && + access["" + value] === true + ); + } + + constructor (accessLevel) { + if (!this.__proto__.constructor.validate(accessLevel)) throw new Error( - `Invalid access level: ${accessLevel} (must be ranged from -1 to 5)`, + `Invalid access level: ${accessLevel}`, ); - } + this.code = accessLevel; + } + + get accessName () { + return ACCESS_LEVELS[""+this.code] + } +} + +class User { + constructor (name, accessLevel) { this.name = name; - this.accessLevel = accessLevel; + if (accessLevel instanceof AccessLevel) + this.accessLevel = accessLevel; + else + this.accessLevel = new AccessLevel(accessLevel); } static async getUserByName(name) { @@ -41,8 +61,15 @@ class User { User.cache[name] = user; return user; } - // Потом добавить чтение из БД - return null; + // TODO: Потом добавить чтение из БД + //return null; + + const userdata = await global.database.getUserRowByName(name); + if (!userdata) + return null; + const user = new User(userdata.username, userdata.accessLevel); + User.cache[name] = user; + return user; } static getByToken(token) { @@ -50,13 +77,27 @@ class User { return user ?? null; } - getUserForAPI(getFullInfo = false) { - return { + getUserForAPI(getFullInfo = false, fields = "*") { + const getted = { username: this.name, - accessLevel: this.accessLevel, + accessLevel: this.accessLevel.code, }; + + if (fields !== "*") { + const selected = Object.fromEntries(fields.split(/\s*,\s*/g).map(field => [field, true])); + for (let field in getted) { + if (!selected[field]) + getted[field] = undefined; + } + } + + return getted; + } + + async getCharacters () { + return []; } } User.cache = {}; -module.exports = { User }; +module.exports = { User, AccessLevel }; diff --git a/server/config.json b/server/config.json index 13686e8..34c3370 100644 --- a/server/config.json +++ b/server/config.json @@ -18,7 +18,8 @@ }, "database": { "dialect": "sqlite", - "path": "./data" + "storage": "./data.db", + "logging": false }, "ai": { "apiType": "kobold" diff --git a/server/database/dbModel.js b/server/database/dbModel.js new file mode 100644 index 0000000..0875f61 --- /dev/null +++ b/server/database/dbModel.js @@ -0,0 +1,124 @@ +const { DataTypes } = require("sequelize"); +const { AccessLevel } = require("../api/utils/user"); +const logger = require("../logger"); +const bcrypt = require("bcrypt"); + +/*const SALT_ROUNDS = Math.round(Math.r) + 6; +const BCRYPT_SALT = bcrypt.genSaltSync(SALT_ROUNDS);*/ +function getSalt () { + //return bcrypt.genSaltSync(10); + return bcrypt.genSaltSync(Math.round(Math.random() * 4) + 6); +} + +module.exports = async function (sequelize, dbObj) { + const dbModel = { + users: sequelize.define("users", { + id: { + type: DataTypes.INTEGER, + allowNull: false, + autoIncrement: true, + primaryKey: true, + comment: "ID of user", + }, + username: { + type: DataTypes.STRING, + allowNull: false, + unique: true, + comment: "Username of user", + validate: { + not: /null/iu, + }, + }, + accessLevel: { + type: DataTypes.INTEGER, + allowNull: false, + defaultValue: false, + comment: "access level of user", + get: function () { + return new AccessLevel(this.getDataValue("accessLevel")); + }, + set: function (value) { + if (value instanceof AccessLevel) + return this.setDataValue("accessLevel", value.code); + if (Number.isInteger(value)) { + if (AccessLevel.validate(value)) + return this.setDataValue("accessLevel", value); + } + throw new TypeError("accessLevel must be AccessLevel object or integer"); + //this.setDataValue('inventory', JSON.stringify(value)); + }, + }, + password: { + type: DataTypes.STRING, + allowNull: false, + unique: false, + comment: "Hash code of password", + set: function (value) { + const hash = bcrypt.hashSync(value, getSalt()); + this.setDataValue('password', hash); + }, + get: function () { + const checkHash = this.getDataValue("password"); + return async function (password) { + return await new Promise((rs, rj) => { + bcrypt.compare(password, checkHash, (err, res) => { + if (err) + return rs(false); + logger.debug("isRes is", res); + rs(res); + }); + }); + } + }, + }, + isActivate: { + type: DataTypes.BOOLEAN, + allowNull: false, + defaultValue: true, + comment: "Is user activated", + }, + }), + + userCharacters: sequelize.define("userCharacters", { + id: { + type: DataTypes.INTEGER, + allowNull: false, + autoIncrement: true, + primaryKey: true, + comment: "ID of character", + }, + name: { + type: DataTypes.STRING, + allowNull: false, + unique: true, + comment: "Name of character", + }, + ownerId: { + type: DataTypes.INTEGER, + allowNull: false, + comment: "ID of character's owner", + }, + }), + }; + + dbModel.userCharacters.belongsTo(dbModel.users, { + foreignKey: "ownerId", // field in userCharacters + targetKey: "id", // field in users + onDelete: "CASCADE", // If delete user, characters will be deleted + onUpdate: "CASCADE", + }); + dbModel.users.hasMany(dbModel.userCharacters, { + foreignKey: "ownerId", // field ownerId + sourceKey: "id", + }); + + await sequelize.authenticate(); + const initPromise = Object.entries(dbModel).map(async ([tableName, table]) => { + logger.log(`* init table: ${tableName}`); + await table.sync({ alter: true }); + const rows = await table.findAll(); + await dbObj.cache[tableName]?.(rows, table); + logger.log(`* inited: ${tableName}`); + }); + await Promise.all(initPromise); +}; diff --git a/server/database/index.js b/server/database/index.js new file mode 100644 index 0000000..acf7424 --- /dev/null +++ b/server/database/index.js @@ -0,0 +1,89 @@ +const { Sequelize } = require("sequelize"); +const dbModel = require("./dbModel"); +const logger = require("../logger"); + +class DatabaseCache { + async users (rows, table) { + // Add root user only where root user is activated + if (global.config.rootuser.activated) { + const isRootHasOnDB = rows.find(x => x.username === global.config.rootuser.login); + const rootLevel = 5; // TODO: replace it to AccessLevel.root in future + + if (!isRootHasOnDB) { + await table.create({ + username: global.config.rootuser.login, + accessLevel: rootLevel, + password: "", + }); + return await this.users(( + await table.findAll() + ), table); + } else if (!rows.find(x => x.username === global.config.rootuser.login && x.accessLevel.code === rootLevel)) { + throw new Error(`Username ${ + global.config.rootuser.login + } is busy`); + } + } + this.users.rows = rows; + this.users.byUsername = new Object(); + this.users.byId = new Object(); + + for (let row of rows) { + this.users.byUsername[row.username] = row; + this.users.byId[row.id] = row; + } + + // Add user info + this.users.add = async (row, isCreate = false) => { + if (isCreate) + await table.create(row); + + let addedData = null; + if (row.username) + addedData = await table.findAll({ + where: { + username: row.username + } + })[0]; + else if (row.id) + addedData = await table.findAll({ + where: { + id: row.id + } + })[0]; + + if (!addedData) throw new Error("Invalid row information"); + this.users.rows.push(addedData); + // By keys + this.users.byUsername[addedData.username] = addedData; + this.users.byId[addedData.id] = addedData; + } + } +} + +class Database { + constructor(sequlizeData) { + this.sequlize = new Sequelize(sequlizeData); + } + + async init(fromHandler = false) { + global.server.toggleLock(true); + this.cache = new DatabaseCache(); + await dbModel(this.sequlize, this); + global.server.toggleLock(false); + if (!fromHandler) + global.server.on("config.update", async ({ + oldConfig, newConfig + }) => { + if (JSON.stringify(oldConfig.database) !== JSON.stringify(newConfig.database)) + await this.init(true); + }); + } + + // Return ROW of 'users' table by username + async getUserRowByName (name) { + return this.cache.users.byUsername[name]; + } +} + +module.exports = Database; diff --git a/server/server.js b/server/server.js index 731e2d7..5f96cf4 100644 --- a/server/server.js +++ b/server/server.js @@ -4,6 +4,8 @@ const parser = require("./packet-parser"); const frontend = require("./frontend"); const websocket = require("./ws-handler"); const crypt = require("./crypt"); +const Database = require("./database"); + const EventEmitter = require("events"); const net = require("net"); const fs = require("fs"); @@ -11,10 +13,29 @@ const path = require("path"); const { API } = require("./api"); +// So.. what part of the WeakMap/Server class made you think +// Oh! Developer of this AI client agrees that +// [Object Server].isLocked can be writeable? +const isLockedServer = new WeakMap(); + class Server extends EventEmitter { constructor() { super(); this.on("config.update", ({ oldConfig, newConfig }) => {}); + //this.isLocked = false; + isLockedServer.set(this, false); + } + + set isLocked (_) { + throw new Error("isLocked not writeable"); + } + + get isLocked () { + return isLockedServer.get(this); + } + + toggleLock (mode = true) { + isLockedServer.set(this, !!mode); } run(address, port) { @@ -185,7 +206,8 @@ const defaultConfigData = { }, database: { dialect: "sqlite", - path: "./data", + storage: "./data.db", + logging: false }, ai: { apiType: "kobold", @@ -199,63 +221,12 @@ const defaultConfigData = { global.server = new Server(); cfgHandle(global.server, defaultConfigData, logger); - -logger.log("Current config:", global.config); - -global.server.run(global.config.server.address, global.config.server.port); - -/* -if (global.config.server.secureMode) { - async function initEncryption () { - const datadir = global.config.server["ssl-path"] ?? path.join(__dirname, "/encryption"); - - function generate () { - logger.log("Generate new keys..."); - global.openSSH = crypt.generateOpenSSH({ - commonName: global.config.server.info.name - }, { - days: 365, - pkcs7: true, - clientCertificate: true, - clientCertificateCN: "AI Adventure Labs", - algorithm: 'sha256', - keySize: 2048, - }); - fs.writeFileSync(path.join(datadir, "public.pem"), global.openSSH.public_key.bytes); - fs.writeFileSync(path.join(datadir, "private.pem"), global.openSSH.private_key.bytes); - fs.writeFileSync(path.join(datadir, "cert.crt"), global.openSSH.cert); - } - - const readFilePromise = new Promise((resolve, reject) => { - fs.readFile(path.join(datadir, "public.pem"), (err, public_key) => { - if (err) { - return resolve(generate()); - } - try { - public_key = new crypt.CryptKey(public_key); - const private_key = new crypt.CryptKey(fs.readFileSync(path.join(datadir, "private.pem"))); - const cert = fs.readFileSync(path.join(datadir, "cert.crt")); - - global.openSSH = new crypt.OpenSSH({ - public_key, private_key, cert - }); - resolve(); - } catch (_) { - resolve(generate()); - } - }); - }); - - await readFilePromise; - - logger.log(`loaded from ${datadir}`); - }; - initEncryption().then(() => { - server.run(global.config.server.address, global.config.server.port); - }).catch(e => { - throw e; +global.database = new Database(global.config.database); +global.database.init() + .then(() => { + logger.log("Current config:", global.config); + global.server.run(global.config.server.address, global.config.server.port); + }) + .catch((err) => { + throw err; }); -} -else - server.run(global.config.server.address, global.config.server.port); -*/