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.
408 lines
15 KiB
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') {
|
|
}
|
|
|