diff --git a/.gitignore b/.gitignore index 3c34374..0dff01b 100644 --- a/.gitignore +++ b/.gitignore @@ -24,4 +24,4 @@ node_modules # Config config.mjs -kv-data +*.sqlite3 diff --git a/server/apps/auth/middlewares.mjs b/server/apps/auth/middlewares.mjs index 0b2df6c..e190f96 100644 --- a/server/apps/auth/middlewares.mjs +++ b/server/apps/auth/middlewares.mjs @@ -10,7 +10,7 @@ import { ACCOUNT_TYPE, Account } from './model.mjs'; */ export async function loadAccount(ctx, next) { if (ctx.state.user != null && ctx.state.user.auth_step == 'done' && ctx.state.account == null) { - ctx.state.account = await Account.load(ctx.state.user.uid); + ctx.state.account = await Account.findByPk(ctx.state.user.uid); } await next(); } @@ -28,6 +28,6 @@ export async function adminRequired(ctx, next) { await next(); } else { ctx.status = 403; // 403 (Forbidden) - ctx.body = { error: 'You must be an administrator to access this resource.' }; + ctx.body = { error: 'You must be an administrator to access this resource.', errno: 'EPERM' }; } } diff --git a/server/apps/auth/model.mjs b/server/apps/auth/model.mjs index 37733ad..968958e 100644 --- a/server/apps/auth/model.mjs +++ b/server/apps/auth/model.mjs @@ -1,6 +1,5 @@ -import { BASIC_TYPES } from '@og/binary-struct'; import { UUID } from '@og/uuid'; -import { Model, BaseModel } from '../../kv/index.mjs'; +import { DataTypes, Model } from 'sequelize'; import * as config from '../../config.mjs'; import { hashPassword, isWeakPassword } from './password.mjs'; import { timingSafeEqual } from 'node:crypto'; @@ -25,45 +24,25 @@ function calcAccountUUID(handle) { /** * A Model representing an user account. * @class - * @extends {BaseModel} + * @property {UUID} uid + * @property {number} type + * @property {string} handle + * @property {string} name + * @property {Buffer} passwd + * @property {Buffer?} totp_key + * @property {Date} created_at + * @property {number} rating + * @property {Map} preference */ -export class Account extends Model('Account', config.kv_instance) { - /** @type {UUID} */ - uid; - - /** @type {number} */ - type; - - /** @type {string} */ - handle; - - /** @type {string} */ - name; - - /** @type {Buffer} */ - passwd; - - /** @type {Buffer} */ - totp_key = Buffer.alloc(20, 0); - - /** @type {Date} */ - created_at = new Date(); - - /** @type {number} */ - rating = 0; - - /** @type {Map} */ - preference = new Map(); - - /** @type {UUID[]} */ - replays = []; - +export class Account extends Model { /** - * Getter of the primary key of Account. - * @returns {string|null} The primary key value or null if not applicable. + * Asynchronously loads a Account instance based on its handle. + * @param {string} handle - the handle of the account to load. + * @returns {Promise} - The loaded account instance or null if not found. + * @async */ - get pk() { - return this.uid?.toString(); + static loadByHandle(handle) { + return this.findByPk(calcAccountUUID(handle).toString()); } asInfo() { @@ -74,7 +53,6 @@ export class Account extends Model('Account', config.kv_instance) { name: this.name, created_at: Math.floor(this.created_at.getTime() / 1000), rating: this.rating, - replays: this.replays.map(r => r.toString()), }; } @@ -101,14 +79,6 @@ export class Account extends Model('Account', config.kv_instance) { this.passwd = hashPassword(passwd); } - /** - * Clears the password of the account. - */ - clearPassword() { - this.type = ACCOUNT_TYPE.temporary; - this.passwd = hashPassword(''); - } - /** * Checks if the given password is correct. * @param {string} passwd - password. @@ -127,22 +97,12 @@ export class Account extends Model('Account', config.kv_instance) { return this.type != ACCOUNT_TYPE.banned; } - /** - * Asynchronously loads a Account instance based on its handle. - * @param {string} handle - the handle of the account to load. - * @returns {Promise} - The loaded account instance or null if not found. - * @async - */ - static loadByHandle(handle) { - return this.load(calcAccountUUID(handle).toString()); - } - /** * Creates an new account. * @param {{handle: string, name: string, type: number, plaintext_password: string | null}} * @returns {Account} Created account. */ - static create({ handle, name, type, plaintext_password }) { + static createInstance({ handle, name, type, plaintext_password }) { if (handle == null) { throw new InvalidArgumentError('handle', 'a handle is required'); } @@ -153,32 +113,70 @@ export class Account extends Model('Account', config.kv_instance) { throw new InvalidArgumentError('name', 'name is too long or too short'); } - if (type != null && !Object.values(ACCOUNT_TYPE).includes(type)) { + if (!Object.values(ACCOUNT_TYPE).includes(type)) { throw new InvalidArgumentError('type', 'unknown type'); } - const res = new this(); + if (typeof plaintext_password != 'string') { + throw new InvalidArgumentError('passwd', 'incorrect type'); + } + + const res = this.build(); res.setHandle(handle); res.name = name; res.type = type ?? ACCOUNT_TYPE.normal; - if (plaintext_password != null) { - res.setPassword(plaintext_password); - } else { - res.clearPassword(); - } + res.setPassword(plaintext_password); return res; } - - static typedef = [ - { field: 'uid', type: BASIC_TYPES.uuid }, - { field: 'type', type: BASIC_TYPES.u8 }, - { field: 'handle', type: BASIC_TYPES.str }, - { field: 'name', type: BASIC_TYPES.str }, - { field: 'passwd', type: BASIC_TYPES.raw(32) }, - { field: 'totp_key', type: BASIC_TYPES.raw(20) }, - { field: 'created_at', type: BASIC_TYPES.DateTime }, - { field: 'rating', type: BASIC_TYPES.i32 }, - { field: 'preference', type: BASIC_TYPES.StringMap }, - { field: 'replays', type: BASIC_TYPES.array(BASIC_TYPES.uuid) }, - ]; }; + +Account.init({ + uid: { + type: DataTypes.UUID, + allowNull: false, + primaryKey: true, + unique: true, + get() { + return new UUID(this.getDataValue('uid')); + }, + set(uuid) { + this.setDataValue('uid', uuid.toString()); + } + }, + type: { + type: DataTypes.INTEGER, + allowNull: false, + defaultValue: ACCOUNT_TYPE.banned, + validate: { + isIn: [Object.values(ACCOUNT_TYPE)], + }, + }, + handle: { + type: DataTypes.STRING(32), + allowNull: false, + unique: true, + }, + name: { + type: DataTypes.STRING, + allowNull: false, + }, + passwd: { + type: DataTypes.STRING(32, true), + allowNull: false, + }, + totp_key: { + type: DataTypes.STRING(20, true), + allowNull: true, + }, + rating: { + type: DataTypes.INTEGER, + allowNull: false, + defaultValue: 1500, + } +}, { + sequelize: config.db_instance, + modelName: 'Account', + timestamps: true, + createdAt: 'created_at', + updatedAt: false, +}); diff --git a/server/apps/auth/views.mjs b/server/apps/auth/views.mjs index 6884c89..fb3bc15 100644 --- a/server/apps/auth/views.mjs +++ b/server/apps/auth/views.mjs @@ -4,6 +4,7 @@ import { loadAccount } from './middlewares.mjs'; import { ACCOUNT_TYPE, Account } from './model.mjs'; import { isValidUUID } from '@og/uuid'; import * as config from '../../config.mjs'; +import { ValidationErrorItem } from 'sequelize'; /** @typedef {import('koa').Context} Context */ /** @typedef {import('koa').Next} Next */ @@ -22,10 +23,10 @@ export const login_view = [ const account = await Account.loadByHandle(handle); if (account == null || !account.checkPassword(passwd)) { ctx.status = 400; // Bad Request. - ctx.body = { error: 'Authentication failed: handle or password is incorrect.' }; + ctx.body = { error: 'Authentication failed: handle or password is incorrect.', errno: 'EPASSWD' }; } else if (!account.canLogin()) { ctx.status = 400; // Bad Request. - ctx.body = { error: 'Your account is banned or restricted.' }; + ctx.body = { error: 'Your account is banned or restricted.', errno: 'EBANNED' }; } else { const token = jwt.sign({ uid: account.uid.toString(), @@ -75,27 +76,26 @@ export const refresh_token_view = [ export const signup_view = [ syllableRequired('handle', 'string'), - syllableRequired('name', 'string'), syllableRequired('passwd', 'string'), /** * @param {Context} ctx * @param {Next} next */ async function(ctx, next) { - /** @type {{handle: string, name: string, passwd: string}} */ - const {handle, name, passwd} = ctx.request.body; - let account = Account.create({ - handle, name, + /** @type {{handle: string, passwd: string}} */ + const {handle, passwd} = ctx.request.body; + + if (Account.loadByHandle(handle) != null) { + ctx.status = 400; + ctx.body = { error: 'handle is taken', errno: 'EUNIQUE' }; + } + + let account = Account.createInstance({ + handle, type: ACCOUNT_TYPE.normal, plaintext_password: passwd, }); - if (!await account.canCreate()) { - ctx.status = 400; - ctx.body = { error: 'Handle is taken or cannot be used.' }; - return; - } - await account.save(); ctx.status = 204; } @@ -109,10 +109,10 @@ export const account_info_view = [ */ async function(ctx, next) { /** @type {Account?} */ - let account = await Account.load(ctx.params.uid); + let account = await Account.findByPk(ctx.params.uid); if (account == null) { ctx.status = 404; // 404 Not Found - ctx.body = { error: 'requested user is not found' }; + ctx.body = { error: 'requested user is not found', errno: 'E404' }; } else { ctx.status = 200; ctx.body = account.asInfo(); diff --git a/server/config.template.mjs b/server/config.template.mjs index 7943260..18b1a39 100644 --- a/server/config.template.mjs +++ b/server/config.template.mjs @@ -1,4 +1,4 @@ -import { FileSystemKeyValueStorage } from './kv/index.mjs'; +import { Sequelize } from 'sequelize'; // PRODUCTION: set this to false in production! export const debug = true; @@ -14,7 +14,7 @@ export const secret = Buffer.from('m5GDNW92K/c+YdDTlai3lG0wwL8h63LcLD9XZOSz8Lsqo // PRODUCTION: change the folder to a suitable place // key-value storage instance to use -export const kv_instance = new FileSystemKeyValueStorage('./kv-data'); +export const db_instance = new Sequelize('sqlite:server-db.sqlite3', { logging: false }); // Time for a JWT to expire. export const jwt_expire = '48h'; diff --git a/server/kv/index.mjs b/server/kv/index.mjs deleted file mode 100644 index 908aab7..0000000 --- a/server/kv/index.mjs +++ /dev/null @@ -1,318 +0,0 @@ -import { deserializeFromBinary, serializeToBinary } from '@og/binary-struct'; -import { VirtualMethodNotImplementedError } from '@og/error-utils'; -import { pluralize } from 'inflection'; -import fs from 'node:fs/promises'; -import path from 'node:path'; - -/** - * @abstract - * @class - * @classdesc Represents a key-value storage interface. - */ -export class BaseKeyValueStorage { - /** - * Sets a value for the given class and primary key. - * @param {string} cls - The class identifier. - * @param {string} pk - The primary key. - * @param {ArrayBuffer} val - The value to set. - * @abstruct - * @async - */ - async set(cls, pk, val) { - throw new VirtualMethodNotImplementedError(); - } - - /** - * Retrieves the value associated with the given class and primary key. - * @param {string} cls - The class identifier. - * @param {string} pk - The primary key. - * @abstruct - * @async - * @returns {Promise} - The value associated with the class and primary key, or null if not found. - */ - async get(cls, pk) { - throw new VirtualMethodNotImplementedError(); - } - - /** - * Checks whether a value exists for the given class and primary key. - * @param {string} cls - The class identifier. - * @param {string} pk - The primary key. - * @abstruct - * @async - * @returns {Promise} - True if the value exists, otherwise false. - */ - async has(cls, pk) { - throw new VirtualMethodNotImplementedError(); - } - - /** - * Deletes the value associated with the given class and primary key. - * @param {string} cls - The class identifier. - * @param {string} pk - The primary key. - * @abstruct - * @async - */ - async destroy(cls, pk) { - throw new VirtualMethodNotImplementedError(); - } - - /** - * Initializes or updates the class. - * @param {string} cls - The class identifier. - * @abstruct - * @async - */ - async touchClass(cls) { } -} - -/** - * Represents a key-value storage implementation using the file system. - * @class - * @extends {BaseKeyValueStorage} - */ -export class FileSystemKeyValueStorage extends BaseKeyValueStorage { - /** - * Creates an instance of FileSystemKeyValueStorage. - * @param {string} root - The root directory where key-value data will be stored. - */ - constructor(root) { - super(); - /** @private @type {string} */ - this.root = root; - } - - /** - * Sets a value for the given class and primary key. - * @param {string} cls - The class identifier. - * @param {string} pk - The primary key. - * @param {ArrayBuffer} val - The value to set. - * @abstruct - */ - async set(cls, pk, val) { - const filePath = this._getFilePath(cls, pk); - await fs.writeFile(filePath, Buffer.from(val)); - } - - /** - * Retrieves the value associated with the given class and primary key. - * @param {string} cls - The class identifier. - * @param {string} pk - The primary key. - * @abstruct - * @returns {Promise} - The value associated with the class and primary key, or null if not found. - */ - async get(cls, pk) { - const filepath = this._getFilePath(cls, pk); - try { - const data = await fs.readFile(filepath); - return data.buffer; - } catch (error) { - if (error.code === 'ENOENT') { // File not found - return null; - } else { - throw error; - } - } - } - - /** - * Checks whether a value exists for the given class and primary key. - * @param {string} cls - The class identifier. - * @param {string} pk - The primary key. - * @abstruct - * @returns {Promise} - True if the value exists, otherwise false. - */ - async has(cls, pk) { - const filepath = this._getFilePath(cls, pk); - try { - await fs.access(filepath); - return true; - } catch { - return false; - } - } - - /** - * Deletes the value associated with the given class and primary key. - * @param {string} cls - The class identifier. - * @param {string} pk - The primary key. - * @abstruct - */ - async destroy(cls, pk) { - const filepath = this._getFilePath(cls, pk); - await fs.unlink(filepath); - } - - /** - * Initializes or updates the class by creating a folder for it if it doesn't exist. - * @param {string} cls - The class identifier. - * @abstruct - */ - async touchClass(cls) { - const cls_dir = path.join(this.root, cls); - await fs.mkdir(cls_dir, { recursive: true }); - } - - /** - * Constructs the file path for a given class and primary key. - * @private - * @param {string} cls - The class identifier. - * @param {string} pk - The primary key. - * @returns {string} - The file path. - */ - _getFilePath(cls, pk) { - const cls_dir = path.join(this.root, cls); - return path.join(cls_dir, pk); - } -} - - -/** - * Abstract base class for all models. - * @abstract - * @class - */ -export class BaseModel { - /** - * Gets the primary key of the model. - * @abstract - * @returns {string|null} The primary key value or null if not applicable. - */ - get pk() { - throw new VirtualMethodNotImplementedError(); - } - - /** - * Asynchronously saves the current model instance to the key-value storage. - * @throws {Error} - Throws an error if the primary key is not set. - * @returns {Promise} - * @abstract - */ - async save() { - throw new VirtualMethodNotImplementedError(); - } - - /** - * Asynchronously destroys the current model instance in the key-value storage. - * @throws {Error} - Throws an error if the primary key is not set. - * @returns {Promise} - * @abstract - */ - async destroy() { - throw new VirtualMethodNotImplementedError(); - } - - /** - * Asynchronously checks if the current model instance can be created in the key-value storage. - * @returns {Promise} - True if the model can be created, false otherwise. - * @abstract - */ - async canCreate() { - throw new VirtualMethodNotImplementedError(); - } - - /** - * Asynchronously loads a model instance from the key-value storage based on its primary key. - * @param {string} pk - The primary key of the model to load. - * @returns {Promise<*>} The loaded model instance or null if not found. - * @abstract - */ - static async load(pk) { - throw new VirtualMethodNotImplementedError(); - } -} - -/** - * A mixin function that creates a model class with basic CRUD operations and storage methods. - * - * @param {string} name - The name of the model. - * @param {BaseKeyValueStorage} kv - The key-value storage for data persistence. - * @returns {Function} - The generated model class. - */ -export function Model(name, kv) { - const cls = pluralize(name); - - /** - * @class - * @extends BaseModel - */ - return class extends BaseModel { - /** - * Asynchronously saves the current model instance to the key-value storage. - * @throws {Error} - Throws an error if the primary key is not set. - * @returns {Promise} - */ - async save() { - const pk = this.pk; - if (pk == null) { - throw new PrimaryKeyNotSetError(); - } - - const buf = serializeToBinary(this, this.constructor); - await kv.set(cls, pk, buf); - } - - /** - * Asynchronously destroys the current model instance in the key-value storage. - * @throws {Error} - Throws an error if the primary key is not set. - * @returns {Promise} - */ - async destroy() { - const pk = this.pk; - if (pk == null) { - throw new PrimaryKeyNotSetError(); - } - - await kv.destroy(cls, pk); - } - - /** - * Asynchronously checks if the current model instance can be created in the key-value storage. - * @returns {Promise} - True if the model can be created, false otherwise. - */ - async canCreate() { - const pk = this.pk; - if (pk == null) { - return false; - } - - return !(await kv.has(cls, pk)); - } - - /** - * Asynchronously loads a model instance from the key-value storage based on its primary key. - * @param {string} pk - The primary key of the model to load. - * @returns {Promise<*>} - The loaded model instance or null if not found. - */ - static async load(pk) { - const buf = await kv.get(cls, pk); - if (buf == null) { - return null; - } - - return deserializeFromBinary(new DataView(buf), this); - } - - /** - * Initializes the key-value storage for the current class. - */ - static { - kv.touchClass(cls); - } - }; -} - -/** - * Represents an error that is thrown when the primary key of an object is not set. - * @class - * @extends {Error} - */ -class PrimaryKeyNotSetError extends Error { - /** - * Constructs a new PrimaryKeyNotSetError instance with a default error message. - * @constructor - */ - constructor() { - super('pk() returns null'); - } -}; diff --git a/server/middlewares/auth-required.mjs b/server/middlewares/auth-required.mjs index 3647c7a..4203386 100644 --- a/server/middlewares/auth-required.mjs +++ b/server/middlewares/auth-required.mjs @@ -9,7 +9,7 @@ export function loginRequired(ctx, next) { if (ctx.state.user == null || ctx.state.user.auth_step != 'done') { ctx.status = 401; // 401 (Unauthorized) - ctx.body = { error: 'You are not identified or your authentication process is incomplete.' }; + ctx.body = { error: 'You are not identified or your authentication process is incomplete.', errno: 'EAUTH' }; } else { return next(); } diff --git a/server/middlewares/error-response.mjs b/server/middlewares/error-response.mjs index 62e3b92..3c9ea28 100644 --- a/server/middlewares/error-response.mjs +++ b/server/middlewares/error-response.mjs @@ -13,7 +13,7 @@ export async function errorAsResponse(ctx, next) { } catch (err) { if (err instanceof InvalidArgumentError) { ctx.status = 400; - ctx.body = { error: err.toString() }; + ctx.body = { error: err.toString(), errno: 'EIA' }; } else { ctx.status = 500; throw err; diff --git a/server/package.json b/server/package.json index 1b82293..ef87405 100644 --- a/server/package.json +++ b/server/package.json @@ -25,9 +25,13 @@ "koa": "^2.15.0", "koa-bodyparser": "^4.4.1", "koa-jwt": "^4.0.4", - "koa-router": "^12.0.1" + "koa-router": "^12.0.1", + "mariadb": "^3.2.3", + "sequelize": "^6.37.1", + "sqlite3": "^5.1.7" }, "devDependencies": { - "@types/koa__router": "^12.0.4" + "@types/koa__router": "^12.0.4", + "sequelize-cli": "^6.6.2" } }