/* This contains the base classes for the `Model`s in the database. It uses the [lib/ormish.js](/admin/#/docs/lib/ormish.js) module to build up classes for: + `User` -- A simple user model. + `Payment` -- A Payment model that is simple but can support Stripe, Paypal, and BTCPayServer. + `Product` -- A simple Product for the site. + `Media` -- Media information common to video and music files, but could work with anything. + `Livestream` -- Metadata for livestream sessions (see `client/pages/Livestream.svelte`). + `Site` -- Simple key=value store for settings you might want in your site. As with all other code in this project these models are _complete_ but don't have everything under the sun. For example, it acts as if `User` pays one time to access the entire site and all `Product`. That is one very valid model of payment, and it's the simplest one which you can then build on. The rationale is you get a starter kit that you can then mold into what you need because this is simple enough to understand while also being useful to get started. ### Tests You should look at the tests in `tests/models` for many examples of using these. */ import { knex, Model } from './ormish.js'; import bcrypt from 'bcryptjs'; import {v4 as uuid} from "uuid"; import logging from "./logging.js"; import assert from "assert"; import crypto from "crypto"; const log = logging.create("lib/models.js"); /* Number of bytes in the reset code sent. */ export const RESET_CODE_SIZE = 8; /* Bytes in the unique unsubscribe code. */ export const UNSUB_CODE_SIZE = 16; /* The `User` model contains the majority of settings for the users for the site. It should contain almost everything you need, and won't change much. If you want to add more options and settings I recommend either create a `knex` migration to change it, or add a `Model.has_one` relation that uses a second table for additional features. + [user](/admin/#/table/user) - admin this table */ export class User extends Model.from_table('user') { /* Authenticates the user based on their `username` and `password`. The `username` can be anything, but I've chosen email since that seems to be the most common unique username method. It first finds that user, then it uses the [bcryptjs](https://www.npmjs.com/package/bcryptjs) or [bcrypt](https://www.npmjs.com/package/bcrypt) modules. ### bcrypt vs bcryptjs By default this code uses [bcryptjs](https://www.npmjs.com/package/bcryptjs) as that has lower installation requirements. The big problem with all C++ based modules is the fagility of `node-gyp` and `node-pre-gyp`. If you need the speed then change the import above to use `bcrypt` instead of `bcryptjs`, but be prepared to have possible errors randomly when you install it. ### Usage ```javascript const authenticated = await User.auth("help@learnjsthehardway.com", "notmypassword"); ``` + `username String` -- The "username" which is currently the `user.email` field. + `password String` -- The password as the user types it. _DO NOT STORE THIS._ + ___return___ `User` or `undefined` if the user is not found or not authenticated */ static async auth(username, password) { try { const uname = username.toLowerCase(); const res = await knex('user').where({email: uname}).select(); if(res === undefined) { log.error(res, "Received undefined but knex claims only Array returns."); return undefined; } else if(res.length > 1) { log.error(res, `Multiple accounts under the same email ${uname}`); return undefined; } else if(res.length === 0) { // no user found return undefined; } else { // user found, check password const user = res[0]; const good = bcrypt.compareSync(password, user.password); return good ? user : undefined; } } catch(error) { log.error(error); return undefined; } } /* Performs all the cleanup and checks needed for a registration. It will ensure that the password and password_repeat are the same, lowercase the email, clean out unwanted attributes with `User.clean`, generate required keys, etc. + `attr Object` - attributes usually from a web form + ___return___ `Object`, `undefined` or the new user on success */ static async register(attr) { let user = undefined; if(attr.password != attr.password_repeat) { return undefined; } else { user = User.clean(attr); } user.email = user.email.toLowerCase(); // validate here? let exists = await User.first({email: user.email}); if(exists) { log.error("User exists", user.email); return undefined; } else { user.password = User.encrypt_password(user.password); user.unsubkey = User.random_hex(UNSUB_CODE_SIZE); let res = await knex('user').insert(user); user.id = res[0]; return res.length == 0 ? undefined : user; } } /* Uses the [crypt](https://nodejs.org/docs/latest-v18.x/api/crypto.html) module to generate a random hex string of size `bytes`. + `size Number` -- size of bytes, then converted to hex. + ___return___ String */ static random_hex(size) { assert(size, `random_hex size can't be falsy ${ size }`); return crypto.randomBytes(size).toString("hex"); } /* Given a string for a password this will use the `bcrypt.hashSync` function to encrypt it. It generates the salt for each password it encrypts, which I believe is the correct way to do it. If that's not then someone should update the `bcrypt` docs. + `password String` -- A string to password encrypt. + ___return___ String` */ static encrypt_password(password) { assert(password, `Password cannot be falsy: ${ password }`); let salt = bcrypt.genSaltSync(10); return bcrypt.hashSync(password, salt); } /* Changes the user's unsubscribe from email setting, to determine if you should email them or not. It uses the `User.unsubscribe` attribute, so `true` means they are ___not___ receiving emails, and `false` means they ___will__ receive emails. + `setting boolean` -- Whether they are unsubscribed or not. + ___return___ `number` -- Count of records changed, should be 1. */ async emails(setting) { // don't change it if it's already set this way if(this.unsubscribe !== setting) { return await User.update({id: this.id}, { unsubscribe: setting, unsubscribed_on: Date.now() }); } else { return 1; } } /* Has many mapping where Users have many Payments, but Payment has only one User. */ async payments() { return await this.has_many(Payment, { user_id: this.id }); } } /* Represents a payment in any system with Stripe, BTCPayServer, and Paypal currently supported. It contains `sys_primary_id` and `sys_secondary_id` fields for generic "id" fields that these services love to throw around, with an `internal_id` for our tracking. Every payment processor has different uses for the various IDs returned, so refer to the `/api/payments/` modules for how these are mapped. + [payment](/admin/#/table/payment) - admin this table */ export class Payment extends Model.from_table('payment') { /* Payment has only one User, but User has many Payments. */ get user() { return this.user_id !== undefined ? this.has_one(User, { id: this.user_id }) : undefined; } /* Used in testing and debugging to create a fake payment with no specific system. Look in `tests/models/payment.js` to see how this works: ```javascript let test1 = await Payment.fake_payment(); ``` + ___return___ A fake `Payment` object for testing. */ static fake_payment() { return Payment.insert({ system: 'fake', status: 'complete', internal_id: Payment.gen_internal_id(), sys_primary_id: Payment.gen_internal_id(), sys_secondary_id: Payment.gen_internal_id(), sys_created_on: new Date() }); } /* Generates a random `uuid()` for use in the `internal_id` field. _WARNING_: It's not clear if the `uuid()` function is actually secure or not, so don't rely on this for any cryptography. */ static gen_internal_id() { return uuid(); } /* Indicates whether the `user` has paid for the site's product. Keep in mind that this is using a model of a _single_ payment for access to the site. As with all of The Bandolier it's not a full featured payment system, but it is enough to get started. If you wanted to support subscriptions this would most likely work, but if you want to sell multiple products or create a full store then you've got some work to do. Another thing that this model won't support is the "merchant" experience, where you let other people sell their stuff on your site. That is the _most_ complex online business to run as it requires receiving money and giving money to other people. ### Study This This contains a unique use of the `lib/ormish.js:Model.first` and `knex` to construct a complex query with a custom builder: ```javascript // ignore refunded payments const payment = await Payment.first(builder => { builder .whereNotIn("status", ["refunded", "failed", "pending"]) .where({user_id: user.id}) }, ["user_id", "system", "status", "status_reason"]); ``` This doesn't come up too often, but when you need it this little snippet is gold. The idea is that `Model.first` can accept anything that `knex` accepts, which includes a builder callback to create a refined query. + `user User` - The user to confirm payment on. + ___return__ `[true, Payment]` if paid or `[false, undefined]` if not paid. */ static async paid(user) { assert(user !== undefined, "Invalid user given to Payment.paid"); assert(user.id !== undefined, "User object given has an user.id === undefined."); // ignore refunded payments const payment = await Payment.first(builder => { builder .whereNotIn("status", ["refunded", "failed", "pending"]) .where({user_id: user.id}) }, ["user_id", "system", "status", "status_reason"]); // it's paid if there's a payment and it is complete const paid = payment !== undefined && payment.status === "complete"; // this now returns paid and also paid is false with more info about why return [paid, payment]; } } /* Simple `media` table for storing metadata on various media you will probably need to display. It's debatable whether this should be in the database or simply output to straight `.json` files. The database is easier to edit, but `.json` files are way easier to host and transmit. I may come up with a hybrid model eventually. + [media](/admin/#/table/media) - admin this table */ export class Media extends Model.from_table("media") { } /* A useful simle key=value table for storing various settings and stats for the site. It contains a `set`, `get`, `increment`, and `decrement` methods for efficient(ish) counting events or actions. I think if you want to do this at significant scale get a time series database. For simple things this works. + [site](/admin/#/table/site) - admin this table */ export class Site extends Model.from_table("site") { /* Get the value for the given key. This is a `json` type in the database, so YMMV if your database isn't SQlite3. + `key string` -- The key in the database. + ___return___ `Object` -- Uses JSON.parse(), so not really tested on other databases. */ static async get(key) { const row = await knex(this.table_name).first().where({key}); return row !== undefined ? JSON.parse(row.value) : undefined; } /* Set a key to a value, which is a generic JSON object type. This is the inverse of `get()`. You really need to think what goes in here. If it's very large you'll probably want to look at a document database or store it on the disk. + `key string` -- The key to set. + `value Any JSON type` - This is run through `JSON.stringify`. + ___return___ See `lib/ormish.js:Model.upsert` for what is returned. */ static async set(key, value) { const json_value = JSON.stringify(value); return await Site.upsert({key, value: json_value}, "key"); } /* Performs a SQL increment operation, but it's dubious whether this works outside of SQLite3. I believe since SQLite3 technically stores everything as a string it mostly ignores the `json` type of the column and treats it like an integer. The reason you need this operation is it's more efficient to blindly tell the database to add to a counter it knows, then to select that number, add to it, then set it again. ___TODO___: figure out how to get sqlite3 to do a return of the value. + `key string` - The key to set. + `count number` - How much to increment. */ static async increment(key, count) { return await knex(this.table_name).where({key}).increment("value", count); } /* Same as `increment()` but the inverse. Same parameters. */ static async decrement(key, count) { return await knex(this.table_name).where({key}).decrement("value", count); } } /* Supports Livestream information, which includes viewer stats, scheduling information and other features you need when doing a livestream. + [livestream](/admin/#/table/livestream) - admin this table */ export class Livestream extends Model.from_table("livestream") { /* Return the `Media` attached to this (1:1) `Livestream`. This is set after the stream is done and is stored for replay later. */ media() { return Media.first({id: this.media_id}); } /* Increments the viewer count of an arbitrary `Livestream` by its `id`. + `id number` -- `Livestream` to increment. + ___return___ Currently should return whatever `knex`'s `increment` returns. */ static add_viewers(id) { return knex(this.table_name).where({id}).increment('viewer_count', 1); } } /* Represents a Product in the system. This is very generic and meant to be something you change. In my [learnjsthehardway.com](https://learnjsthehardway.com) site it's called `Course` instead so you might see some leftover from the conversion. I changed it since not everyone has a course in their system, but most people have a product. You'll also notice this has no connection to `Purchase`. That's because this demo site is simple and there's just one `Payment` per user to give access to all `Product`s. In most other sites you'd create a `Purchase` object that would tie together the `User`, `Product`, and `Payment` each time they bought things. + [product](/admin/#/table/product) - admin this table */ export class Product extends Model.from_table('product') { }