You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 
bandolier-website/lib/models.js

408 lines
15 KiB

/*
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') {
}